├── .circleci └── config.yml ├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE.md ├── README.md ├── SECURITY.md ├── asset ├── .DS_Store └── img │ └── example.png ├── config └── config.exs ├── lib ├── strong_migrations.ex ├── strong_migrations │ ├── classifier.ex │ ├── classifiers │ │ ├── add_index_concurrently_in_transaction.ex │ │ ├── add_index_not_concurrently.ex │ │ ├── default_is_function.ex │ │ ├── drop_index_concurrently_in_transaction.ex │ │ ├── drop_table.ex │ │ ├── remove_column.ex │ │ └── rename_column.ex │ ├── loader.ex │ ├── migration.ex │ ├── parser.ex │ ├── reasons_translator.ex │ └── validator.ex └── task │ └── strong_migrations │ └── migrate.ex ├── mix.exs ├── mix.lock ├── renovate.json ├── test-results └── .gitkeep └── test ├── fixtures ├── loader │ ├── different_extensions │ │ ├── 20210303123050_create_users_table.ex │ │ ├── 20210303123050_create_users_table.exs │ │ └── 20210303123050_create_users_table.txt │ ├── migrations │ │ └── 20200202213700_create_users_table.exs │ └── no_migrations │ │ └── .gitkeep └── parser │ ├── add_column_with_default_function_with_comment.exs │ ├── add_column_with_default_value.exs │ ├── add_column_with_func_default.exs │ ├── add_column_with_sequential_default.exs │ ├── add_if_not_exists_column_with_func_default.exs │ ├── create_index.exs │ ├── create_index_concurrently.exs │ ├── create_index_concurrently_many_options.exs │ ├── create_unique_index.exs │ ├── create_unique_index_concurrently.exs │ ├── create_with_column_with_func_default.exs │ ├── disable_ddl_transaction_false.exs │ ├── disable_ddl_transaction_true.exs │ ├── disable_migration_lock_false.exs │ ├── disable_migration_lock_true.exs │ ├── drop_index.exs │ ├── drop_index_concurrently.exs │ ├── drop_table.exs │ ├── drop_table_and_remove_column.exs │ ├── drop_table_and_remove_column_if_exist.exs │ ├── drop_table_if_exists.exs │ ├── drop_two_tables.exs │ ├── drop_two_tables_if_exists.exs │ ├── empty.exs │ ├── modify_column_with_func_default.exs │ ├── remove_column.exs │ ├── remove_column_if_exists.exs │ ├── remove_two_columns.exs │ ├── remove_two_columns_if_exists.exs │ ├── rename_column.exs │ ├── rename_two_columns.exs │ ├── safety_assured_create_index.exs │ ├── safety_assured_create_indexes.exs │ └── safety_assured_one_of_two_opts.exs ├── strong_migrations_test.exs ├── test_doubles └── classifiers │ ├── always_failed.ex │ └── always_success.ex ├── test_helper.exs └── unit └── strong_migrations ├── classifiers ├── add_index_concurrently_in_transaction_test.exs ├── add_index_not_concurrently_test.exs ├── default_is_function_test.exs ├── drop_index_concurrently_in_transaction_test.exs ├── drop_table_test.exs ├── remove_column_test.exs └── rename_column_test.exs ├── loader_test.exs ├── parser_test.exs ├── reasons_translator_test.exs └── validator_test.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | elixir-orb: fresha/elixir-orb@0.2 5 | 6 | workflows: 7 | version: 2 8 | ci: 9 | jobs: 10 | - elixir-orb/test: 11 | context: Hex 12 | - elixir-orb/static-check: 13 | context: Hex 14 | - hold-for-dev-publish: 15 | type: approval 16 | requires: 17 | - elixir-orb/test 18 | - elixir-orb/static-check 19 | filters: 20 | branches: 21 | ignore: 22 | - master 23 | - elixir-orb/dev-publish: 24 | context: Hex 25 | requires: 26 | - hold-for-dev-publish 27 | - elixir-orb/deploy: 28 | context: Hex 29 | requires: 30 | - elixir-orb/test 31 | - elixir-orb/static-check 32 | filters: 33 | branches: 34 | only: 35 | - master 36 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # These migrations are just for testing purposes 2 | /priv/repo/migrations 3 | 4 | # The directory Mix will write compiled artifacts to. 5 | /_build/ 6 | 7 | # If you run "mix test --cover", coverage assets end up here. 8 | /cover/ 9 | 10 | # The directory Mix downloads your dependencies sources to. 11 | /deps/ 12 | 13 | # Where third-party dependencies like ExDoc output generated docs. 14 | /doc/ 15 | 16 | # Ignore .fetch files in case you like to edit your project deps locally. 17 | /.fetch 18 | 19 | # If the VM crashes, it generates a dump, let's ignore it too. 20 | erl_crash.dump 21 | 22 | # Also ignore archive artifacts (built via "mix archive.build"). 23 | *.ez 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | strong_migrations-*.tar 27 | 28 | # Temporary files for e.g. tests 29 | /tmp 30 | 31 | /priv/plts/*.plt 32 | /priv/plts/*.plt.hash 33 | 34 | 35 | # emacs temp files 36 | *.*~ 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](https://semver.org/). 7 | 8 | 9 | ## [Unreleased] 10 | 11 | 12 | ## [0.2.0] - 2022-07-12 13 | 14 | ### Changed 15 | 16 | - Moved to the Fresha organization 17 | 18 | ### Fixed 19 | 20 | - New image on CircleCI 21 | 22 | ### Added 23 | 24 | - Checking for default values being function calls (https://github.com/fresha/strong_migrations/pull/11) 25 | - CHANGELOG.md 26 | - SECURITY.md 27 | 28 | ## [0.1.5] - 2021-11-11 29 | 30 | ### Fixed 31 | 32 | - Tasks returns error code when migrations are not safe 33 | 34 | ## [0.1.4] - 2021-11-11 35 | 36 | ### Fixed 37 | 38 | - Ignore `down` section of migrations 39 | 40 | ### Changed 41 | 42 | - Better examples of usage 43 | 44 | 45 | ## [0.1.3] - 2021-11-04 46 | 47 | ### Added 48 | 49 | - `use StorngMigrations` 50 | 51 | ### Changed 52 | 53 | - Updated Readme with better examples 54 | 55 | ## [0.1.2] - 2021-11-02 56 | 57 | ### Fixed 58 | 59 | - CI setup 60 | - Type warnings 61 | 62 | ## [0.1.1] - 2021-10-29 63 | 64 | ### Added 65 | 66 | - Introduce `safetry_assured` as macro 67 | 68 | ## [0.1.0] - 2021-10-29 69 | 70 | ### Added 71 | - Initial version 72 | 73 | 74 | 75 | [Unreleased]: https://github.com/fresha/strong_migrations/compare/v0.2.0...HEAD 76 | [0.2.0]: https://github.com/fresha/strong_migrations/compare/v0.1.5...v0.2.0 77 | [0.1.5]: https://github.com/fresha/strong_migrations/compare/v0.1.4...v0.1.5 78 | [0.1.4]: https://github.com/fresha/strong_migrations/compare/v0.1.3...v0.1.4 79 | [0.1.3]: https://github.com/fresha/strong_migrations/compare/v0.1.2...v0.1.3 80 | [0.1.2]: https://github.com/fresha/strong_migrations/compare/v0.1.1...v0.1.2 81 | [0.1.1]: https://github.com/fresha/strong_migrations/compare/v0.1.0...v0.1.1 82 | [0.1.0]: https://github.com/fresha/strong_migrations/compare/vb0bea57599f67514ab4386c51d6ed7dcdc9b6e32...v0.1.0 83 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | . @fresha/team-scalpel 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 Fresha 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StrongMigrations 2 | 3 | [![Build Status](https://github.com/surgeventures/strong_migrations/workflows/CI/badge.svg)](https://github.com/surgeventures/strong_migrations/actions) 4 | [![Module Version](https://img.shields.io/hexpm/v/strong_migrations.svg)](https://hex.pm/packages/strong_migrations) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/strong_migrations/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/strong_migrations.svg)](https://hex.pm/packages/strong_migrations) 7 | [![License](https://img.shields.io/hexpm/l/strong_migrations.svg)](https://github.com/surgeventures/strong_migrations/blob/master/LICENSE.md) 8 | [![Last Updated](https://img.shields.io/github/last-commit/surgeventures/strong_migrations.svg)](https://github.com/surgeventures/strong_migrations/commits/master) 9 | 10 | **Catch unsafe migrations in your Elixir application** 11 | 12 | ## Table of Contents 13 | 14 | - [What is it?](#what-is-it) 15 | - [How to install?](#how-to-install) 16 | - [How to configure?](#how-to-configure) 17 | - [Similar packages](#similar-packages) 18 | 19 | ## What is it 20 | 21 | `strong_migrations` is a library that protects your application from invoking unsafe migrations, they needs to be marked as a safe. 22 | 23 | 1. Analyze migrations if they are safe. 24 | 2. If migrations are unsafe -> print errors. 25 | 3. If migrations are safe -> use `ecto.migrate`. 26 | 27 | You can also use a macro of `StrongMigrations` like `safety_assured` to be sure it's safe and you can run migrations with specific changes. Example 28 | 29 | ```elixir 30 | defmodule SafetyAssuredDropTable do 31 | @moduledoc false 32 | 33 | use StrongMigrations 34 | 35 | def change do 36 | safety_assured do 37 | drop(table(:users)) 38 | end 39 | end 40 | end 41 | ``` 42 | 43 | ![](asset/img/example.png) 44 | 45 | #### Features 46 | 47 | - checking if your migrations are adding an index concurrently in transaction 48 | - checking if your migrations are adding an index but not concurrently 49 | - checking if your migrations are removing an index concurrently in transaction 50 | - checking if your migrations are renaming columns (it's always better to remove old and add new column) 51 | - checking if your migrations are removing columns 52 | - checking if your migrations are removing tables 53 | - mark `safety assured do: drop table(:posts)` or multiline when you're sure it's safe 54 | - check if default is a function when altering a table 55 | ... tbd 56 | 57 | ## How to install 58 | 59 | The package can be installed by adding `strong_migrations` to your list of dependencies in `mix.exs` as follows. Also, it's worth adding an alias like: `ecto.migrate -> strong_migrations.migrate` and thanks to that you'll be sure that all migrations were checked before running. 60 | 61 | Update your `mix.exs`: 62 | 63 | ```elixir 64 | def deps do 65 | [ 66 | {:strong_migrations, "~> 0.1"} 67 | ] 68 | end 69 | ``` 70 | 71 | Optionally, you can add an alias: 72 | 73 | ```elixir 74 | "ecto.migrate": "strong_migrations.migrate" 75 | ``` 76 | 77 | And, another option is to use `StrongMigrations` as a default for generated migrations. Just add following line to your `config.exs` file. 78 | 79 | ```elixir 80 | config :ecto_sql, migration_module: StrongMigrations 81 | ``` 82 | 83 | ## How to configure 84 | 85 | If you want to specify which classifiers you want to use, please add to your `config.exs` following lines 86 | 87 | ```elixir 88 | config :strong_migrations, 89 | classifiers: [ 90 | StrongMigrations.Classifiers.AddIndexConcurrentlyInTransaction, 91 | StrongMigrations.Classifiers.AddIndexNotConcurrently, 92 | StrongMigrations.Classifiers.DropIndexConcurrentlyInTransaction, 93 | StrongMigrations.Classifiers.DropTable, 94 | StrongMigrations.Classifiers.RemoveColumn, 95 | StrongMigrations.Classifiers.RenameColumn, 96 | StrongMigrations.Classifiers.DefaultIsFunction 97 | ] 98 | ``` 99 | 100 | If you want to specify migration paths available in your project (not default -> `priv/repo/migrations`), please add to your `config.exs` following lines 101 | 102 | ```elixir 103 | config :strong_migrations, 104 | migration_paths: [ 105 | "priv/repo/migrations", 106 | "apps/*/priv/repo/migrations", 107 | "my/fancy/path/to/migrations" 108 | ], 109 | ``` 110 | 111 | ## Similar Packages 112 | 113 | - https://github.com/ankane/strong_migrations (Ruby) 114 | - https://github.com/Artur-Sulej/excellent_migrations 115 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 0.x.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | In case you find a security vulnerability, report it to security@fresha.com. 12 | We will triage it and fix if necessary. 13 | -------------------------------------------------------------------------------- /asset/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fresha/strong_migrations/bcee405daec505d33de77c82720be9923c157e25/asset/.DS_Store -------------------------------------------------------------------------------- /asset/img/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fresha/strong_migrations/bcee405daec505d33de77c82720be9923c157e25/asset/img/example.png -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # Sample configuration: 6 | # 7 | # config :strong_migrations, 8 | # migration_paths: [ 9 | # "priv/repo/migrations", 10 | # "apps/*/priv/repo/migrations", 11 | # "my/fancy/path/to/migrations" 12 | # ], 13 | # classifiers: [ 14 | # StrongMigrations.Classifiers.AddIndexConcurrentlyInTransaction, 15 | # StrongMigrations.Classifiers.AddIndexNotConcurrently, 16 | # StrongMigrations.Classifiers.DropIndexConcurrentlyInTransaction, 17 | # StrongMigrations.Classifiers.DropTable, 18 | # StrongMigrations.Classifiers.RemoveColumn, 19 | # StrongMigrations.Classifiers.RenameColumn, 20 | # StrongMigrations.Classifiers.DefaultIsFunction 21 | # ] 22 | -------------------------------------------------------------------------------- /lib/strong_migrations.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations do 2 | @moduledoc """ 3 | Documentation for `StrongMigrations`. 4 | """ 5 | 6 | alias StrongMigrations.Loader 7 | alias StrongMigrations.Parser 8 | alias StrongMigrations.ReasonsTranslator 9 | alias StrongMigrations.Validator 10 | 11 | @type migration_path() :: String.t() 12 | @type migration_file() :: String.t() 13 | @type validation_result() :: {migration_file(), [atom()]} 14 | @type reason() :: String.t() 15 | 16 | @doc """ 17 | Starts analyze of the application's migrations. 18 | 19 | ## Examples 20 | 21 | iex> StrongMigrations.analyze() 22 | :safe 23 | 24 | """ 25 | @spec analyze([migration_path()]) :: :safe | {:unsafe, [reason]} 26 | def analyze(migration_paths \\ []) do 27 | migration_paths 28 | |> Loader.load() 29 | |> Parser.parse() 30 | |> Validator.validate() 31 | |> ReasonsTranslator.translate() 32 | end 33 | 34 | defmacro __using__(_opts) do 35 | quote do 36 | use Ecto.Migration 37 | import StrongMigrations 38 | end 39 | end 40 | 41 | defmacro safety_assured(do: expression) do 42 | quote do 43 | unquote(expression) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifier.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifier do 2 | @moduledoc """ 3 | Identifies an interface of the Classifier 4 | """ 5 | 6 | alias StrongMigrations.Migration 7 | 8 | @callback classify(Migration.t()) :: :ok | {:error, atom()} 9 | end 10 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifiers/add_index_concurrently_in_transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.AddIndexConcurrentlyInTransaction do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(%{create_index_concurrently: true, disable_ddl_transaction: false}), do: failed() 10 | 11 | def classify(%{create_index_concurrently: true, disable_migration_lock: false}), do: failed() 12 | 13 | def classify(_migration), do: :ok 14 | 15 | defp failed do 16 | {:error, :add_index_concurrently_in_transaction} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifiers/add_index_not_concurrently.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.AddIndexNotConcurrently do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(%{create_index: true, create_index_concurrently: false}), do: failed() 10 | def classify(_migration), do: :ok 11 | 12 | defp failed do 13 | {:error, :add_index_not_concurrently} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifiers/default_is_function.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.DefaultIsFunction do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(%{default_is_function: true}), do: failed() 10 | def classify(_migration), do: :ok 11 | 12 | defp failed, do: {:error, :function_as_default_is_not_safety_assured} 13 | end 14 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifiers/drop_index_concurrently_in_transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.DropIndexConcurrentlyInTransaction do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(%{drop_index_concurrently: true, disable_ddl_transaction: false}), do: failed() 10 | 11 | def classify(%{drop_index_concurrently: true, disable_migration_lock: false}), do: failed() 12 | 13 | def classify(_migration), do: :ok 14 | 15 | defp failed do 16 | {:error, :drop_index_concurrently_in_transaction} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifiers/drop_table.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.DropTable do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(%{drop_table: true}), do: failed() 10 | def classify(_migration), do: :ok 11 | 12 | defp failed, do: {:error, :drop_table_is_not_safety_assured} 13 | end 14 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifiers/remove_column.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.RemoveColumn do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(%{remove_column: true}), do: failed() 10 | def classify(_migration), do: :ok 11 | 12 | defp failed, do: {:error, :remove_column_is_not_safety_assured} 13 | end 14 | -------------------------------------------------------------------------------- /lib/strong_migrations/classifiers/rename_column.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.RenameColumn do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(%{rename_column: true}), do: failed() 10 | def classify(_migration), do: :ok 11 | 12 | defp failed, do: {:error, :rename_column_is_not_safe} 13 | end 14 | -------------------------------------------------------------------------------- /lib/strong_migrations/loader.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Loader do 2 | @moduledoc """ 3 | Loads migration files from a given paths 4 | """ 5 | 6 | @doc """ 7 | Returns a list of migration files in specified paths (`:migration_paths`). 8 | """ 9 | @spec load([StrongMigrations.migration_path()]) :: [StrongMigrations.migration_file()] 10 | def load(paths) do 11 | paths 12 | |> get_migration_paths() 13 | |> Enum.flat_map(fn path -> 14 | path 15 | |> Path.join("*.exs") 16 | |> Path.wildcard() 17 | |> Enum.uniq() 18 | end) 19 | end 20 | 21 | defp get_migration_paths([]) do 22 | Application.get_env(:strong_migrations, :migration_paths, [ 23 | "priv/repo/migrations", 24 | "apps/*/priv/repo/migrations" 25 | ]) 26 | end 27 | 28 | defp get_migration_paths(paths), do: paths 29 | end 30 | -------------------------------------------------------------------------------- /lib/strong_migrations/migration.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Migration do 2 | @moduledoc """ 3 | Represents details related to a specific migration 4 | """ 5 | 6 | use TypedStruct 7 | 8 | typedstruct do 9 | field(:file_path, String.t(), default: "") 10 | field(:disable_ddl_transaction, boolean(), default: false) 11 | field(:disable_migration_lock, boolean(), default: false) 12 | field(:create_index, boolean(), default: false) 13 | field(:drop_index, boolean(), default: false) 14 | field(:create_index_concurrently, boolean(), default: false) 15 | field(:drop_index_concurrently, boolean(), default: false) 16 | field(:rename_column, boolean(), default: false) 17 | field(:remove_column, boolean(), default: false) 18 | field(:drop_table, boolean(), default: false) 19 | field(:safety_assured, [:atom], default: []) 20 | field(:default_is_function, boolean(), default: false) 21 | end 22 | 23 | @spec new(String.t()) :: t() 24 | def new(file_path) do 25 | %__MODULE__{file_path: file_path} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/strong_migrations/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Parser do 2 | @moduledoc """ 3 | The module responsible for reading an ASTand generating a bucket of the information of migration's content 4 | """ 5 | 6 | alias StrongMigrations.Migration 7 | 8 | @doc """ 9 | Parses given migrations into a struct with information about the content 10 | """ 11 | @spec parse([StrongMigrations.migration_file()]) :: list() 12 | def parse(migration_files) do 13 | migration_files 14 | |> Stream.map(&file_path_to_ast/1) 15 | |> Stream.map(&analyze_code/1) 16 | |> Enum.to_list() 17 | end 18 | 19 | defp file_path_to_ast(file_path) do 20 | {file_path, 21 | file_path 22 | |> File.read!() 23 | |> Code.string_to_quoted!()} 24 | end 25 | 26 | defp analyze_code({file_path, {:defmodule, _, [_, [do: {:__block__, _, body}]]}}) do 27 | parse_body(body, Migration.new(file_path)) 28 | end 29 | 30 | defp analyze_code({file_path, _}), do: Migration.new(file_path) 31 | 32 | defp parse_body([{:use, _, _} | tail], acc), do: parse_body(tail, acc) 33 | 34 | defp parse_body([{:@, _, [{:disable_ddl_transaction, _, [true]}]} | tail], acc) do 35 | parse_body(tail, %{acc | disable_ddl_transaction: true}) 36 | end 37 | 38 | defp parse_body([{:@, _, [{:disable_migration_lock, _, [true]}]} | tail], acc) do 39 | parse_body(tail, %{acc | disable_migration_lock: true}) 40 | end 41 | 42 | defp parse_body([{:def, _, [{_, _, _}, [do: {:safety_assured, _, _}]]} | tail], acc) do 43 | parse_body(tail, acc) 44 | end 45 | 46 | defp parse_body([{:def, _, [{:down, _, _}, _]} | tail], acc) do 47 | parse_body(tail, acc) 48 | end 49 | 50 | defp parse_body( 51 | [{:def, _, [_, [do: {:create, _, [{:index, _, [_, _, [concurrently: true]]}]}]]} | tail], 52 | acc 53 | ) do 54 | parse_body(tail, %{acc | create_index_concurrently: true}) 55 | end 56 | 57 | defp parse_body( 58 | [ 59 | {:def, _, [_, [do: {:create, _, [{:unique_index, _, [_, _, [concurrently: true]]}]}]]} 60 | | tail 61 | ], 62 | acc 63 | ) do 64 | parse_body(tail, %{acc | create_index_concurrently: true}) 65 | end 66 | 67 | defp parse_body( 68 | [{:def, _, [_, [do: {:create, _, [{:index, _, [_, _, opts]}]}]]} | tail], 69 | acc 70 | ) do 71 | parse_body(tail, %{acc | create_index_concurrently: Keyword.get(opts, :concurrently, false)}) 72 | end 73 | 74 | defp parse_body( 75 | [{:def, _, [_, [do: {:create, _, [{:unique_index, _, [_, _, opts]}]}]]} | tail], 76 | acc 77 | ) do 78 | parse_body(tail, %{acc | create_index_concurrently: Keyword.get(opts, :concurrently, false)}) 79 | end 80 | 81 | defp parse_body( 82 | [{:def, _, [_, [do: {:drop, _, [{:index, _, [_, _, [concurrently: true]]}]}]]} | tail], 83 | acc 84 | ) do 85 | parse_body(tail, %{acc | drop_index_concurrently: true}) 86 | end 87 | 88 | defp parse_body([{:def, _, [_, [do: {:create, _, [{:index, _, _}]}]]} | tail], acc) do 89 | parse_body(tail, %{acc | create_index: true}) 90 | end 91 | 92 | defp parse_body([{:def, _, [_, [do: {:create, _, [{:unique_index, _, _}]}]]} | tail], acc) do 93 | parse_body(tail, %{acc | create_index: true}) 94 | end 95 | 96 | defp parse_body([{:def, _, [_, [do: {:drop, _, [{:index, _, _}]}]]} | tail], acc) do 97 | parse_body(tail, %{acc | drop_index: true}) 98 | end 99 | 100 | defp parse_body([{:def, _, [_, [do: {:drop, _, [{:table, _, _}]}]]} | tail], acc) do 101 | parse_body(tail, %{acc | drop_table: true}) 102 | end 103 | 104 | defp parse_body([{:def, _, [_, [do: {:drop_if_exists, _, [{:table, _, _}]}]]} | tail], acc) do 105 | parse_body(tail, %{acc | drop_table: true}) 106 | end 107 | 108 | defp parse_body( 109 | [ 110 | {:def, _, 111 | [_, [do: {:rename, _, [{:table, _, [_table]}, _column_name, [to: _new_column_name]]}]]} 112 | | tail 113 | ], 114 | acc 115 | ) do 116 | parse_body(tail, %{acc | rename_column: true}) 117 | end 118 | 119 | defp parse_body( 120 | [ 121 | {:def, _, [_, [do: {:alter, _, [{:table, _, _}, [do: {:__block__, _, opts}]]}]]} | tail 122 | ], 123 | acc 124 | ) do 125 | acc = parse_complex_body(opts, acc) 126 | 127 | parse_body(tail, acc) 128 | end 129 | 130 | defp parse_body([{:def, _, [_, [do: {:alter, _, opts}]]} | tail], acc) do 131 | acc = parse_complex_body(opts, acc) 132 | 133 | parse_body(tail, acc) 134 | end 135 | 136 | defp parse_body([{:def, _, [_, [do: {:__block__, _, opts}]]} | tail], acc) do 137 | acc = parse_complex_body(opts, acc) 138 | 139 | parse_body(tail, acc) 140 | end 141 | 142 | defp parse_body([_head | tail], acc) do 143 | parse_body(tail, acc) 144 | end 145 | 146 | defp parse_body([], acc), do: acc 147 | 148 | defp parse_complex_body([{:create, _, [{:index, _, [_, _, [concurrently: true]]}]} | tail], acc) do 149 | parse_complex_body(tail, %{acc | create_index_concurrently: true}) 150 | end 151 | 152 | defp parse_complex_body( 153 | [{:create, _, [{:unique_index, _, [_, _, [concurrently: true]]}]} | tail], 154 | acc 155 | ) do 156 | parse_complex_body(tail, %{acc | create_index_concurrently: true}) 157 | end 158 | 159 | defp parse_complex_body([{:create, _, [{:index, _, [_, _]}]} | tail], acc) do 160 | parse_complex_body(tail, %{acc | create_index: true}) 161 | end 162 | 163 | defp parse_complex_body([{:create, _, [{:unique_index, _, [_, _]}]} | tail], acc) do 164 | parse_complex_body(tail, %{acc | create_index: true}) 165 | end 166 | 167 | defp parse_complex_body([{:drop, _, [{:index, _, [_, _, [concurrently: true]]}]} | tail], acc) do 168 | parse_complex_body(tail, %{acc | drop_index_concurrently: true}) 169 | end 170 | 171 | defp parse_complex_body([{:drop, _, [{:index, _, [_, _]}]} | tail], acc) do 172 | parse_complex_body(tail, %{acc | drop_index: true}) 173 | end 174 | 175 | defp parse_complex_body( 176 | [{:rename, _, [{:table, _, [_table]}, _column_name, [to: _new_column_name]]} | tail], 177 | acc 178 | ) do 179 | parse_complex_body(tail, %{acc | rename_column: true}) 180 | end 181 | 182 | defp parse_complex_body([[do: {:remove, _, [_column]}] | tail], acc) do 183 | parse_complex_body(tail, %{acc | remove_column: true}) 184 | end 185 | 186 | defp parse_complex_body([[do: {:remove_if_exists, _, [_column]}] | tail], acc) do 187 | parse_complex_body(tail, %{acc | remove_column: true}) 188 | end 189 | 190 | defp parse_complex_body([{:remove, _, [_column]} | tail], acc) do 191 | parse_complex_body(tail, %{acc | remove_column: true}) 192 | end 193 | 194 | defp parse_complex_body([{:remove_if_exists, _, [_column]} | tail], acc) do 195 | parse_complex_body(tail, %{acc | remove_column: true}) 196 | end 197 | 198 | defp parse_complex_body([[do: {method, _, [_col_name, _col_type, col_opts]}] | tail], acc) 199 | when method in [:add, :add_if_not_exists, :modify] do 200 | default_is_function = default_is_function?(col_opts) 201 | parse_complex_body(tail, %{acc | default_is_function: default_is_function}) 202 | end 203 | 204 | defp parse_complex_body( 205 | [{:alter, _, [{:table, _, [_table]}, [do: {:remove, _, [_column]}]]} | tail], 206 | acc 207 | ) do 208 | parse_complex_body(tail, %{acc | remove_column: true}) 209 | end 210 | 211 | defp parse_complex_body( 212 | [{:alter, _, [{:table, _, [_table]}, [do: {:remove_if_exist, _, [_column]}]]} | tail], 213 | acc 214 | ) do 215 | parse_complex_body(tail, %{acc | remove_column: true}) 216 | end 217 | 218 | defp parse_complex_body([{:drop, _, [{:table, _, _table_name}]} | tail], acc) do 219 | parse_complex_body(tail, %{acc | drop_table: true}) 220 | end 221 | 222 | defp parse_complex_body([{:drop_if_exists, _, [{:table, _, _table_name}]} | tail], acc) do 223 | parse_complex_body(tail, %{acc | drop_table: true}) 224 | end 225 | 226 | defp parse_complex_body([_head | tail], acc) do 227 | parse_complex_body(tail, acc) 228 | end 229 | 230 | defp parse_complex_body([], acc), do: acc 231 | 232 | def default_is_function?(opts) do 233 | case Keyword.fetch(opts, :default) do 234 | {:ok, {:fragment, [_line_num], [default_value]}} -> 235 | default_value 236 | |> String.trim() 237 | |> String.contains?(")") 238 | 239 | _ -> 240 | false 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/strong_migrations/reasons_translator.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.ReasonsTranslator do 2 | @moduledoc """ 3 | Module responsible for translating atom errors into human readable communicates 4 | """ 5 | 6 | @spec translate([StrongMigrations.validation_result()]) :: 7 | :safe | {:unsafe, [StrongMigrations.reason()]} 8 | def translate(results) do 9 | results 10 | |> Enum.reject(fn {_migration, reasons} -> reasons == [] end) 11 | |> Enum.flat_map(&to_human_readable/1) 12 | |> case do 13 | [] -> :safe 14 | reasons -> {:unsafe, reasons} 15 | end 16 | end 17 | 18 | defp to_human_readable({migration, reasons}) do 19 | Enum.map(reasons, fn reason -> 20 | """ 21 | Unsafe migration! Reason: #{readable_reason(reason)} 22 | File: #{migration} 23 | """ 24 | end) 25 | end 26 | 27 | defp readable_reason(reason) do 28 | reason |> Atom.to_string() |> String.replace("_", " ") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/strong_migrations/validator.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Validator do 2 | @moduledoc """ 3 | Validates incoming migration's data if they meet requirements or need to be updated 4 | """ 5 | 6 | alias StrongMigrations.Migration 7 | 8 | @spec validate([Migration.t()]) :: [StrongMigrations.validation_result()] 9 | def validate(migrations) do 10 | Enum.map(migrations, fn migration -> 11 | {migration.file_path, apply_classifiers(migration, classifiers())} 12 | end) 13 | end 14 | 15 | defp apply_classifiers(migration, classifiers) do 16 | Enum.reduce(classifiers, [], fn classifier, acc -> 17 | case classifier.classify(migration) do 18 | :ok -> acc 19 | {:error, error} -> acc ++ [error] 20 | end 21 | end) 22 | end 23 | 24 | defp classifiers do 25 | Application.get_env(:strong_migrations, :classifiers, [ 26 | StrongMigrations.Classifiers.AddIndexConcurrentlyInTransaction, 27 | StrongMigrations.Classifiers.AddIndexNotConcurrently, 28 | StrongMigrations.Classifiers.DropIndexConcurrentlyInTransaction, 29 | StrongMigrations.Classifiers.DropTable, 30 | StrongMigrations.Classifiers.RemoveColumn, 31 | StrongMigrations.Classifiers.RenameColumn, 32 | StrongMigrations.Classifiers.DefaultIsFunction 33 | ]) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/task/strong_migrations/migrate.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.StrongMigrations.Migrate do 2 | @moduledoc """ 3 | Task which is an additional layer between the Ecto and StrongMigrations. 4 | Thanks to that we could run analyze of migrations and if it's fine - just run `ecto.migrate` task 5 | """ 6 | 7 | use Mix.Task 8 | require Logger 9 | 10 | alias StrongMigrations 11 | 12 | def run(args) do 13 | case StrongMigrations.analyze() do 14 | :safe -> Mix.Task.run("ecto.migrate", args) 15 | {:unsafe, reasons} -> handle_reasons(reasons) 16 | end 17 | end 18 | 19 | defp handle_reasons(reasons) do 20 | Enum.each(reasons, fn reason -> Logger.warn(reason) end) 21 | 22 | Logger.error("Found #{length(reasons)} unsafe migrations!") 23 | System.stop(1) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/surgeventures/strong_migrations" 5 | @version "0.2.0" 6 | 7 | def project do 8 | [ 9 | app: :strong_migrations, 10 | description: "Catch unsafe migrations in your Elixir application", 11 | version: @version, 12 | elixir: "~> 1.9", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | package: package(), 16 | docs: docs(), 17 | elixirc_paths: elixirc_paths(Mix.env()), 18 | dialyzer: [ 19 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 20 | plt_add_deps: :apps_direct, 21 | plt_add_apps: [:mix] 22 | ] 23 | ] 24 | end 25 | 26 | defp elixirc_paths(:test), do: ["lib", "test/test_doubles"] 27 | defp elixirc_paths(_), do: ["lib"] 28 | 29 | def application do 30 | [ 31 | extra_applications: [:logger, :typed_struct] 32 | ] 33 | end 34 | 35 | defp deps do 36 | [ 37 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, 38 | {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, 39 | {:ex_doc, ">= 0.25.5", only: :dev, runtime: false}, 40 | {:junit_formatter, "~> 3.3", only: [:test]}, 41 | {:typed_struct, "~> 0.1"} 42 | ] 43 | end 44 | 45 | defp docs do 46 | [ 47 | extras: [ 48 | "LICENSE.md": [title: "License"], 49 | "README.md": [title: "Overview"] 50 | ], 51 | main: "readme", 52 | source_url: @source_url, 53 | source_ref: "v#{@version}", 54 | formatters: ["html"] 55 | ] 56 | end 57 | 58 | defp package() do 59 | [ 60 | files: ~w(lib .formatter.exs mix.exs README*), 61 | licenses: ["MIT"], 62 | links: %{ 63 | "Changelog" => "#{@source_url}/commits/master", 64 | "GitHub" => @source_url 65 | } 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, 4 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.16", "607709303e1d4e3e02f1444df0c821529af1c03b8578dfc81bb9cf64553d02b9", [:mix], [], "hexpm", "69fcf696168f5a274dd012e3e305027010658b2d1630cef68421d6baaeaccead"}, 6 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 7 | "ex_doc": {:hex, :ex_doc, "0.25.5", "ac3c5425a80b4b7c4dfecdf51fa9c23a44877124dd8ca34ee45ff608b1c6deb9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "688cfa538cdc146bc4291607764a7f1fcfa4cce8009ecd62de03b27197528350"}, 8 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 9 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 10 | "junit_formatter": {:hex, :junit_formatter, "3.3.0", "bd7914d92885f7cf949dbe1dc6bacf76badfb2c1f5f7b3f9433c20e5b6ec42c8", [:mix], [], "hexpm", "4d040410925324b155ae4c7d41e884a0cdebe53b917bee4f22adf152e987a666"}, 11 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 15 | "typed_struct": {:hex, :typed_struct, "0.2.1", "e1993414c371f09ff25231393b6430bd89d780e2a499ae3b2d2b00852f593d97", [:mix], [], "hexpm", "8f5218c35ec38262f627b2c522542f1eae41f625f92649c0af701a6fab2e11b3"}, 16 | } 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "rangeStrategy": "bump", 6 | "dependencyDashboard": true, 7 | "stabilityDays": 7, 8 | "prCreation": "not-pending" 9 | } 10 | -------------------------------------------------------------------------------- /test-results/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fresha/strong_migrations/bcee405daec505d33de77c82720be9923c157e25/test-results/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/loader/different_extensions/20210303123050_create_users_table.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Repo.Migrations.CreateUsersTable do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create table(:users) do 8 | add(:name, :string) 9 | 10 | timestamps() 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/loader/different_extensions/20210303123050_create_users_table.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Repo.Migrations.CreateUsersTable do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create table(:users) do 8 | add(:name, :string) 9 | 10 | timestamps() 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/loader/different_extensions/20210303123050_create_users_table.txt: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Repo.Migrations.CreateUsersTable do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create table(:users) do 8 | add(:name, :string) 9 | 10 | timestamps() 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/loader/migrations/20200202213700_create_users_table.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Repo.Migrations.CreateUsersTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add(:name, :string) 7 | 8 | timestamps() 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/loader/no_migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fresha/strong_migrations/bcee405daec505d33de77c82720be9923c157e25/test/fixtures/loader/no_migrations/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/parser/add_column_with_default_function_with_comment.exs: -------------------------------------------------------------------------------- 1 | defmodule AddColumnWithFuncDefault do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("test") do 8 | add(:col, :uuid, default: fragment("uuid_generate_v4() -- comment")) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/add_column_with_default_value.exs: -------------------------------------------------------------------------------- 1 | defmodule AddColumnWithDefaultValue do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("test") do 8 | add(:sequntial_id, :integer, default: 0) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/add_column_with_func_default.exs: -------------------------------------------------------------------------------- 1 | defmodule AddColumnWithFuncDefault do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("test") do 8 | add(:col, :uuid, default: fragment("uuid_generate_v4() ")) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/add_column_with_sequential_default.exs: -------------------------------------------------------------------------------- 1 | defmodule AddColumnWithSequentialDefault do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("test") do 8 | add(:sequntial_id, :integer, default: fragment("next_val(products_product_no_seq)")) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/add_if_not_exists_column_with_func_default.exs: -------------------------------------------------------------------------------- 1 | defmodule AddColumnWithFuncDefault do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("test") do 8 | add_if_not_exists(:col, :uuid, default: fragment("uuid_generate_v4()")) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/create_index.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateIndex do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create(index(:users, :email)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/create_index_concurrently.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateIndexConcurrently do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create(index(:users, :email, concurrently: true)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/create_index_concurrently_many_options.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateIndexConcurrentlyManyOptions do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create( 8 | index( 9 | :users, 10 | :email, 11 | where: "type = 0", 12 | unique: true, 13 | name: "yolo", 14 | concurrently: true 15 | ) 16 | ) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/parser/create_unique_index.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateUniqueIndex do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create(unique_index(:users, :email)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/create_unique_index_concurrently.exs: -------------------------------------------------------------------------------- 1 | defmodule CreateUniqueIndexConcurrently do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create(unique_index(:users, :email, concurrently: true)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/create_with_column_with_func_default.exs: -------------------------------------------------------------------------------- 1 | defmodule AddColumnWithFuncDefault do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | create table("test") do 8 | add(:col, :uuid, default: fragment("uuid_generate_v4()")) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/disable_ddl_transaction_false.exs: -------------------------------------------------------------------------------- 1 | defmodule DisableDdlTransactionFalse do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | @disable_ddl_transaction false 7 | 8 | def change do 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/parser/disable_ddl_transaction_true.exs: -------------------------------------------------------------------------------- 1 | defmodule DisableDdlTransactionTrue do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | @disable_ddl_transaction true 7 | 8 | def change do 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/parser/disable_migration_lock_false.exs: -------------------------------------------------------------------------------- 1 | defmodule DisableMigrationLockFalse do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | @disable_migration_lock false 7 | 8 | def change do 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/parser/disable_migration_lock_true.exs: -------------------------------------------------------------------------------- 1 | defmodule DisableMigrationLockTrue do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | @disable_migration_lock true 7 | 8 | def change do 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_index.exs: -------------------------------------------------------------------------------- 1 | defmodule DropIndex do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop(index(:users, :email)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_index_concurrently.exs: -------------------------------------------------------------------------------- 1 | defmodule DropIndexConcurrently do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop(index(:users, :email, concurrently: true)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_table.exs: -------------------------------------------------------------------------------- 1 | defmodule DropTable do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop(table("posts")) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_table_and_remove_column.exs: -------------------------------------------------------------------------------- 1 | defmodule DropTableAndRemoveColumn do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop(table(:users)) 8 | 9 | alter table(:posts) do 10 | remove(:title) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_table_and_remove_column_if_exist.exs: -------------------------------------------------------------------------------- 1 | defmodule DropTableAndRemoveColumnIfExist do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop(table(:users)) 8 | 9 | alter table(:posts) do 10 | remove_if_exist(:title) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_table_if_exists.exs: -------------------------------------------------------------------------------- 1 | defmodule DropTableIfExists do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop_if_exists(table("posts")) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_two_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule DropTwoTables do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop(table("posts")) 8 | drop(table("users")) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/parser/drop_two_tables_if_exists.exs: -------------------------------------------------------------------------------- 1 | defmodule DropTwoTablesIfExists do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | drop_if_exists(table("posts")) 8 | drop_if_exists(table("users")) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/parser/empty.exs: -------------------------------------------------------------------------------- 1 | # It's empty 2 | -------------------------------------------------------------------------------- /test/fixtures/parser/modify_column_with_func_default.exs: -------------------------------------------------------------------------------- 1 | defmodule ModifyColumnWithFuncDefault do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("test") do 8 | modify(:col, :uuid, default: fragment("uuid_generate_v4()")) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/remove_column.exs: -------------------------------------------------------------------------------- 1 | defmodule RemoveColumn do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("posts") do 8 | remove(:title) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/remove_column_if_exists.exs: -------------------------------------------------------------------------------- 1 | defmodule RemoveColumnIfExists do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("posts") do 8 | remove_if_exists(:title) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/parser/remove_two_columns.exs: -------------------------------------------------------------------------------- 1 | defmodule RemoveTwoColumns do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("posts") do 8 | remove(:title) 9 | remove(:description) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/fixtures/parser/remove_two_columns_if_exists.exs: -------------------------------------------------------------------------------- 1 | defmodule RemoveTwoColumnsIfExists do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | alter table("posts") do 8 | remove_if_exists(:title) 9 | remove_if_exists(:description) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/fixtures/parser/rename_column.exs: -------------------------------------------------------------------------------- 1 | defmodule RenameColumn do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | rename(table(:posts), :title, to: :summary) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/rename_two_columns.exs: -------------------------------------------------------------------------------- 1 | defmodule RenameTwoColumns do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def change do 7 | rename(table(:users), :name, to: :full_name) 8 | rename(table(:users), :user_age, to: :age) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/fixtures/parser/safety_assured_create_index.exs: -------------------------------------------------------------------------------- 1 | defmodule SafetyAssuredCreateIndex do 2 | @moduledoc false 3 | 4 | use StrongMigrations 5 | 6 | def change do 7 | safety_assured(do: create(index(:users, :email))) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/parser/safety_assured_create_indexes.exs: -------------------------------------------------------------------------------- 1 | defmodule SafetyAssuredCreateIndex do 2 | @moduledoc false 3 | 4 | use StrongMigrations 5 | 6 | def change do 7 | safety_assured do 8 | create(index(:users, :name)) 9 | create(index(:users, :email)) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/fixtures/parser/safety_assured_one_of_two_opts.exs: -------------------------------------------------------------------------------- 1 | defmodule SafetyAssuredOneOfTwoOpts do 2 | @moduledoc false 3 | 4 | use StrongMigrations 5 | 6 | def change do 7 | safety_assured(do: create(index(:users, :email))) 8 | 9 | drop(table(:posts)) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/strong_migrations_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrationsTest do 2 | use ExUnit.Case 3 | doctest StrongMigrations 4 | 5 | test "project has been analyzed successfully" do 6 | assert StrongMigrations.analyze() == :safe 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_doubles/classifiers/always_failed.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.AlwaysFailed do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(_migration), do: {:error, :failed} 10 | end 11 | -------------------------------------------------------------------------------- /test/test_doubles/classifiers/always_success.ex: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.AlwaysSuccess do 2 | @moduledoc false 3 | 4 | alias StrongMigrations.Classifier 5 | 6 | @behaviour Classifier 7 | 8 | @impl Classifier 9 | def classify(_migration), do: :ok 10 | end 11 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/classifiers/add_index_concurrently_in_transaction_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.AddIndexConcurrentlyInTransactionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Classifiers.AddIndexConcurrentlyInTransaction 5 | alias StrongMigrations.Migration 6 | 7 | test "it has failed when creating an index concurrently with disabled ddl transaction" do 8 | migration = %{ 9 | Migration.new("test.exs") 10 | | create_index_concurrently: true, 11 | disable_migration_lock: true 12 | } 13 | 14 | assert {:error, :add_index_concurrently_in_transaction} == 15 | AddIndexConcurrentlyInTransaction.classify(migration) 16 | end 17 | 18 | test "it has failed when creating an index concurrently with disabled migration lock" do 19 | migration = %{ 20 | Migration.new("test.exs") 21 | | create_index_concurrently: true, 22 | disable_ddl_transaction: true 23 | } 24 | 25 | assert {:error, :add_index_concurrently_in_transaction} == 26 | AddIndexConcurrentlyInTransaction.classify(migration) 27 | end 28 | 29 | test "it has passed when creating an index concurrently with enabled ddl and migration lock" do 30 | migration = %{ 31 | Migration.new("test.exs") 32 | | create_index_concurrently: true, 33 | disable_ddl_transaction: true, 34 | disable_migration_lock: true 35 | } 36 | 37 | assert :ok == AddIndexConcurrentlyInTransaction.classify(migration) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/classifiers/add_index_not_concurrently_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.AddIndexNotConcurrentlyTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Classifiers.AddIndexNotConcurrently 5 | alias StrongMigrations.Migration 6 | 7 | test "it has failed when creating an index not concurrently" do 8 | migration = %{Migration.new("test.exs") | create_index: true} 9 | 10 | assert {:error, :add_index_not_concurrently} == AddIndexNotConcurrently.classify(migration) 11 | end 12 | 13 | test "it has passed when not creating an index" do 14 | migration = %{Migration.new("test.exs") | create_index: false} 15 | 16 | assert :ok == AddIndexNotConcurrently.classify(migration) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/classifiers/default_is_function_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.DefaultIsFunctionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Classifiers.DefaultIsFunction 5 | alias StrongMigrations.Migration 6 | 7 | test "it has failed when trying to drop a table" do 8 | migration = %{Migration.new("test.exs") | default_is_function: true} 9 | 10 | assert {:error, :function_as_default_is_not_safety_assured} == 11 | DefaultIsFunction.classify(migration) 12 | end 13 | 14 | test "it has passed when not drop a table" do 15 | migration = %{Migration.new("test.exs") | default_is_function: false} 16 | 17 | assert :ok == DefaultIsFunction.classify(migration) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/classifiers/drop_index_concurrently_in_transaction_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.DropIndexConcurrentlyInTransactionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Classifiers.DropIndexConcurrentlyInTransaction 5 | alias StrongMigrations.Migration 6 | 7 | test "it has failed when creating an index concurrently with disabled ddl transaction" do 8 | migration = %{ 9 | Migration.new("test.exs") 10 | | drop_index_concurrently: true, 11 | disable_migration_lock: true 12 | } 13 | 14 | assert {:error, :drop_index_concurrently_in_transaction} == 15 | DropIndexConcurrentlyInTransaction.classify(migration) 16 | end 17 | 18 | test "it has failed when creating an index concurrently with disabled migration lock" do 19 | migration = %{ 20 | Migration.new("test.exs") 21 | | drop_index_concurrently: true, 22 | disable_ddl_transaction: true 23 | } 24 | 25 | assert {:error, :drop_index_concurrently_in_transaction} == 26 | DropIndexConcurrentlyInTransaction.classify(migration) 27 | end 28 | 29 | test "it has passed when creating an index concurrently with enabled ddl and migration lock" do 30 | migration = %{ 31 | Migration.new("test.exs") 32 | | drop_index_concurrently: true, 33 | disable_ddl_transaction: true, 34 | disable_migration_lock: true 35 | } 36 | 37 | assert :ok == DropIndexConcurrentlyInTransaction.classify(migration) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/classifiers/drop_table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.DropTableTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Classifiers.DropTable 5 | alias StrongMigrations.Migration 6 | 7 | test "it has failed when trying to drop a table" do 8 | migration = %{Migration.new("test.exs") | drop_table: true} 9 | 10 | assert {:error, :drop_table_is_not_safety_assured} == DropTable.classify(migration) 11 | end 12 | 13 | test "it has passed when not drop a table" do 14 | migration = %{Migration.new("test.exs") | drop_table: false} 15 | 16 | assert :ok == DropTable.classify(migration) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/classifiers/remove_column_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.RemoveColumnTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Classifiers.RemoveColumn 5 | alias StrongMigrations.Migration 6 | 7 | test "it has failed when trying to remove a column" do 8 | migration = %{Migration.new("test.exs") | remove_column: true} 9 | 10 | assert {:error, :remove_column_is_not_safety_assured} == RemoveColumn.classify(migration) 11 | end 12 | 13 | test "it has passed when not remove a column" do 14 | migration = %{Migration.new("test.exs") | remove_column: false} 15 | 16 | assert :ok == RemoveColumn.classify(migration) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/classifiers/rename_column_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.Classifiers.RenameColumnTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Classifiers.RenameColumn 5 | alias StrongMigrations.Migration 6 | 7 | test "it has failed when trying to rename a column" do 8 | migration = %{Migration.new("test.exs") | rename_column: true} 9 | 10 | assert {:error, :rename_column_is_not_safe} == RenameColumn.classify(migration) 11 | end 12 | 13 | test "it has passed when not removing a column" do 14 | migration = %{Migration.new("test.exs") | rename_column: false} 15 | 16 | assert :ok == RenameColumn.classify(migration) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/loader_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.LoaderTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Loader 5 | 6 | setup_all do 7 | Application.put_env(:strong_migrations, :migration_paths, [ 8 | fixtures("migrations") 9 | ]) 10 | end 11 | 12 | test "load empty list of migrations when directory is empty" do 13 | assert [] == Loader.load([fixtures("no_migrations")]) 14 | end 15 | 16 | test "load empty list of migrations when directory does not exist" do 17 | assert [] == Loader.load([fixtures("not_existing_directory")]) 18 | end 19 | 20 | test "load only .exs migrations when different extensions in directory" do 21 | expected_migration = fixtures("different_extensions/20210303123050_create_users_table.exs") 22 | 23 | assert [expected_migration] == Loader.load([fixtures("different_extensions")]) 24 | end 25 | 26 | test "load migrations from the app configuration when not provided paths" do 27 | expected_migration = fixtures("migrations/20200202213700_create_users_table.exs") 28 | 29 | assert [expected_migration] == Loader.load([]) 30 | end 31 | 32 | defp fixtures(migration) do 33 | "test/fixtures/loader/#{migration}" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.ParserTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Migration 5 | alias StrongMigrations.Parser 6 | 7 | test "parsing empty file means nothing should be found" do 8 | file_path = fixtures("empty.exs") 9 | 10 | assert [Migration.new(file_path)] == 11 | Parser.parse([ 12 | file_path 13 | ]) 14 | end 15 | 16 | describe ":disable_ddl_transaction seeking" do 17 | test "should find :disable_ddl_transaction option when enabled" do 18 | [migration] = 19 | Parser.parse([ 20 | fixtures("disable_ddl_transaction_true.exs") 21 | ]) 22 | 23 | assert migration.disable_ddl_transaction == true 24 | end 25 | 26 | test "should not find :disable_ddl_transaction option when disabled" do 27 | [migration] = 28 | Parser.parse([ 29 | fixtures("disable_ddl_transaction_false.exs") 30 | ]) 31 | 32 | assert migration.disable_ddl_transaction == false 33 | end 34 | end 35 | 36 | describe ":disable_migration_lock seeking" do 37 | test "should find :disable_migration_lock option when enabled" do 38 | [migration] = 39 | Parser.parse([ 40 | fixtures("disable_migration_lock_true.exs") 41 | ]) 42 | 43 | assert migration.disable_migration_lock == true 44 | end 45 | 46 | test "should not find :disable_migration_lock option when disabled" do 47 | [migration] = 48 | Parser.parse([ 49 | fixtures("disable_migration_lock_false.exs") 50 | ]) 51 | 52 | assert migration.disable_migration_lock == false 53 | end 54 | end 55 | 56 | test "should not find create index option when safety assured" do 57 | [migration] = 58 | Parser.parse([ 59 | fixtures("safety_assured_create_index.exs") 60 | ]) 61 | 62 | assert migration.create_index == false 63 | end 64 | 65 | test "should not find create index option when safety assured many" do 66 | [migration] = 67 | Parser.parse([ 68 | fixtures("safety_assured_create_indexes.exs") 69 | ]) 70 | 71 | assert migration.create_index == false 72 | end 73 | 74 | test "should not find create index option when safety assured but drop table was not assured" do 75 | [migration] = 76 | Parser.parse([ 77 | fixtures("safety_assured_one_of_two_opts.exs") 78 | ]) 79 | 80 | assert migration.drop_table == true 81 | assert migration.create_index == false 82 | end 83 | 84 | test "should find :create_index option" do 85 | [migration] = 86 | Parser.parse([ 87 | fixtures("create_index.exs") 88 | ]) 89 | 90 | assert migration.create_index == true 91 | end 92 | 93 | test "should find :create_index option when unique_index used" do 94 | [migration] = 95 | Parser.parse([ 96 | fixtures("create_unique_index.exs") 97 | ]) 98 | 99 | assert migration.create_index == true 100 | end 101 | 102 | test "should find :create_index_concurrently option" do 103 | [migration] = 104 | Parser.parse([ 105 | fixtures("create_index_concurrently.exs") 106 | ]) 107 | 108 | assert migration.create_index_concurrently == true 109 | end 110 | 111 | test "should find :create_index_concurrently option for unique_index" do 112 | [migration] = 113 | Parser.parse([ 114 | fixtures("create_unique_index_concurrently.exs") 115 | ]) 116 | 117 | assert migration.create_index_concurrently == true 118 | end 119 | 120 | test "should find :create_index_concurrently with many options" do 121 | [migration] = 122 | Parser.parse([ 123 | fixtures("create_index_concurrently_many_options.exs") 124 | ]) 125 | 126 | assert migration.create_index_concurrently == true 127 | end 128 | 129 | test "should find :drop_index option" do 130 | [migration] = 131 | Parser.parse([ 132 | fixtures("drop_index.exs") 133 | ]) 134 | 135 | assert migration.drop_index == true 136 | end 137 | 138 | test "should find :drop_index_concurrently option" do 139 | [migration] = 140 | Parser.parse([ 141 | fixtures("drop_index_concurrently.exs") 142 | ]) 143 | 144 | assert migration.drop_index_concurrently == true 145 | end 146 | 147 | test "should find :rename_column option" do 148 | [migration] = 149 | Parser.parse([ 150 | fixtures("rename_column.exs") 151 | ]) 152 | 153 | assert migration.rename_column == true 154 | end 155 | 156 | test "should find :rename_column option when two columns to change" do 157 | [migration] = 158 | Parser.parse([ 159 | fixtures("rename_two_columns.exs") 160 | ]) 161 | 162 | assert migration.rename_column == true 163 | end 164 | 165 | test "should find :remove_column option" do 166 | [migration] = 167 | Parser.parse([ 168 | fixtures("remove_column.exs") 169 | ]) 170 | 171 | assert migration.remove_column == true 172 | end 173 | 174 | test "should find :remove_column for _if_exists option " do 175 | [migration] = 176 | Parser.parse([ 177 | fixtures("remove_column_if_exists.exs") 178 | ]) 179 | 180 | assert migration.remove_column == true 181 | end 182 | 183 | test "should find :remove_column option when two columns to change" do 184 | [migration] = 185 | Parser.parse([ 186 | fixtures("remove_two_columns.exs") 187 | ]) 188 | 189 | assert migration.remove_column == true 190 | end 191 | 192 | test "should find :remove_column option for _if_exists when two columns to change" do 193 | [migration] = 194 | Parser.parse([ 195 | fixtures("remove_two_columns_if_exists.exs") 196 | ]) 197 | 198 | assert migration.remove_column == true 199 | end 200 | 201 | test "should find :drop_table option" do 202 | [migration] = 203 | Parser.parse([ 204 | fixtures("drop_table.exs") 205 | ]) 206 | 207 | assert migration.drop_table == true 208 | end 209 | 210 | test "should find :drop_table for _if_exists option " do 211 | [migration] = 212 | Parser.parse([ 213 | fixtures("drop_table_if_exists.exs") 214 | ]) 215 | 216 | assert migration.drop_table == true 217 | end 218 | 219 | test "should find :drop_table option when two columns to change" do 220 | [migration] = 221 | Parser.parse([ 222 | fixtures("drop_two_tables.exs") 223 | ]) 224 | 225 | assert migration.drop_table == true 226 | end 227 | 228 | test "should find :drop_table option for _if_exists when two columns to change" do 229 | [migration] = 230 | Parser.parse([ 231 | fixtures("drop_two_tables_if_exists.exs") 232 | ]) 233 | 234 | assert migration.drop_table == true 235 | end 236 | 237 | describe "mixed operations in single migration" do 238 | test "should find :drop_table and :remove_column in a single migration" do 239 | [migration] = 240 | Parser.parse([ 241 | fixtures("drop_table_and_remove_column.exs") 242 | ]) 243 | 244 | assert migration.drop_table == true 245 | assert migration.remove_column == true 246 | end 247 | 248 | test "should find :drop_table and :remove_column in a single migration with if exist" do 249 | [migration] = 250 | Parser.parse([ 251 | fixtures("drop_table_and_remove_column_if_exist.exs") 252 | ]) 253 | 254 | assert migration.drop_table == true 255 | assert migration.remove_column == true 256 | end 257 | 258 | test "should set :default_is_function if default is a function on change" do 259 | # Following documentation: 260 | # 261 | # https://www.postgresql.org/docs/current/sql-altertable.html#Notes 262 | # 263 | # > Adding a column with a volatile DEFAULT or changing the type 264 | # > of an existing column will require the entire table and its 265 | # > indexes to be rewritten. [...] Table and/or index rebuilds 266 | # > may take a significant amount of time for a large table; and 267 | # > will temporarily require as much as double the disk space. 268 | 269 | assert [%{default_is_function: true}] = 270 | Parser.parse([ 271 | fixtures("add_column_with_func_default.exs") 272 | ]) 273 | 274 | assert [%{default_is_function: true}] = 275 | Parser.parse([ 276 | fixtures("add_if_not_exists_column_with_func_default.exs") 277 | ]) 278 | 279 | assert [%{default_is_function: true}] = 280 | Parser.parse([ 281 | fixtures("modify_column_with_func_default.exs") 282 | ]) 283 | 284 | assert [%{default_is_function: true}] = 285 | Parser.parse([ 286 | fixtures("add_column_with_sequential_default.exs") 287 | ]) 288 | 289 | assert [%{default_is_function: true}] = 290 | Parser.parse([ 291 | fixtures("add_column_with_default_function_with_comment.exs") 292 | ]) 293 | 294 | assert [%{default_is_function: false}] = 295 | Parser.parse([ 296 | fixtures("empty.exs") 297 | ]) 298 | end 299 | 300 | test "default as function in newly created column is safe" do 301 | # This is safe because there are no preexisting rows that should 302 | # be filled. 303 | 304 | assert [%{default_is_function: false}] = 305 | Parser.parse([ 306 | fixtures("create_with_column_with_func_default.exs") 307 | ]) 308 | end 309 | 310 | test "default as value in added column is safe" do 311 | # > When a column is added with ADD COLUMN and a non-volatile 312 | # > DEFAULT is specified, the default is evaluated at the time 313 | # > of the statement and the result stored in the table's 314 | # > metadata. That value will be used for the column for all 315 | # > existing rows. 316 | assert [%{default_is_function: false}] = 317 | Parser.parse([ 318 | fixtures("add_column_with_default_value.exs") 319 | ]) 320 | end 321 | end 322 | 323 | defp fixtures(migration) do 324 | "test/fixtures/parser/#{migration}" 325 | end 326 | end 327 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/reasons_translator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.ReasonsTranslatorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.ReasonsTranslator 5 | 6 | test "translates to safe when no specified reasons for migrations" do 7 | to_translate = [ 8 | {"migration.exs", []} 9 | ] 10 | 11 | assert :safe == ReasonsTranslator.translate(to_translate) 12 | end 13 | 14 | test "translates unsafe when migrations with reasons specified" do 15 | to_translate = [ 16 | {"do_something.exs", [:i_like_trains, :elixir_is_cool]} 17 | ] 18 | 19 | {status, reasons} = ReasonsTranslator.translate(to_translate) 20 | 21 | assert :unsafe == status 22 | 23 | assert [ 24 | "Unsafe migration! Reason: i like trains\nFile: do_something.exs\n", 25 | "Unsafe migration! Reason: elixir is cool\nFile: do_something.exs\n" 26 | ] == reasons 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/unit/strong_migrations/validator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StrongMigrations.ValidatorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias StrongMigrations.Migration 5 | alias StrongMigrations.Validator 6 | 7 | setup do 8 | on_exit(&reset_classifiers/0) 9 | end 10 | 11 | test "validation failed when classificator has applied an error" do 12 | expect_always_success_classifier() 13 | 14 | [{_file, errors}] = 15 | [example_migration()] 16 | |> Validator.validate() 17 | 18 | assert errors == [] 19 | end 20 | 21 | test "validation success when classificator has applied ok status" do 22 | expect_always_failed_classifier() 23 | 24 | [{_file, errors}] = 25 | [example_migration()] 26 | |> Validator.validate() 27 | 28 | assert errors == [:failed] 29 | end 30 | 31 | defp example_migration, do: Migration.new("test.exs") 32 | 33 | defp expect_always_success_classifier do 34 | Application.put_env(:strong_migrations, :classifiers, [ 35 | StrongMigrations.Classifiers.AlwaysSuccess 36 | ]) 37 | end 38 | 39 | defp expect_always_failed_classifier do 40 | Application.put_env(:strong_migrations, :classifiers, [ 41 | StrongMigrations.Classifiers.AlwaysFailed 42 | ]) 43 | end 44 | 45 | defp reset_classifiers do 46 | Application.put_env(:strong_migrations, :classifiers, []) 47 | end 48 | end 49 | --------------------------------------------------------------------------------