├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── ecto_squash │ └── postgres.ex └── mix │ └── tasks │ └── ecto.squash.ex ├── mix.exs ├── mix.lock └── test ├── fixtures ├── 20210505120132_apply_squashed_migrations.exs ├── 20210505120133_ensure_migrated_squash.exs ├── migrations │ ├── 20180103194816_create_users.exs │ ├── 20180122130454_add_phone_to_users.exs │ ├── 20180122130942_create_teams.exs │ ├── 20210505120132_drop_teams.exs │ └── 20220122130942_create_teams.exs └── structure.sql ├── mix └── tasks │ └── ecto.squash_test.exs ├── support └── file_helpers.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | pattern = ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | exclude_prefix = "test/fixtures" 4 | 5 | [ 6 | inputs: 7 | Enum.flat_map( 8 | pattern, 9 | &Path.wildcard(&1, match_dot: true) 10 | ) 11 | |> Enum.reject(fn path -> 12 | String.starts_with?(path, exclude_prefix) 13 | end) 14 | ] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ecto_squash-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2021] [proSapient Limited] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ecto Squash 2 | 3 | ![Package version](https://img.shields.io/hexpm/v/ecto_squash?style=plastic) 4 | 5 | This is a Mix task intended to streamline migration squashing. It replaces 6 | several migrations with a SQL-based migration, which applies schema, and 7 | a second one for making sure that all of the squashed migrations has been 8 | applied and nothing else, before migrating further. 9 | Note: only PostgreSQL is supported yet. 10 | 11 | ## Installation 12 | 13 | The package can be installed by adding `ecto_squash` to your list of 14 | dependencies in `mix.exs` (replace `x.x.x` with current version in the badge): 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:ecto_squash, "~> x.x.x", only: [:dev]} 20 | ] 21 | end 22 | ``` 23 | 24 | ## Examples 25 | 26 | Squash migrations upto and including 20210601033528 into a single one: 27 | 28 | mix ecto.squash --to 20210601033528 29 | mix ecto.squash --to 20210601033528 -r Custom.Repo 30 | 31 | The repository must be set under `:ecto_repos` in the 32 | current app configuration or given via the `-r` option. 33 | 34 | SQL migration will have a filename prefixed with timestamp of the latest 35 | migration squashed. That way it won't be applied if squashed migration is 36 | already there. Another generated migration will have a +1 second 37 | timestamp. 38 | 39 | By default, the migration will be generated to the 40 | "priv/YOUR_REPO/migrations" directory of the current application 41 | but it can be configured to be any subdirectory of `priv` by 42 | specifying the `:priv` key under the repository configuration. 43 | 44 | ## Command line options 45 | 46 | * `--to VERSION` - squash migrations upto and including VERSION 47 | * `-y`, `--yes` - migrate to specified version, remove squashed migrations 48 | and migrate to latest version without asking to confirm actions 49 | * `-r REPO`, `--repo REPO` - the REPO to generate migration for 50 | * `--migrations-path PATH` - the PATH to run the migrations from, 51 | defaults to `priv/repo/migrations` 52 | * `--no-compile` - does not compile applications before running 53 | * `--no-deps-check` - does not check dependencies before running 54 | 55 | ## Configuration 56 | 57 | If `:ecto_sql` app configuration specifies a custom migration module, 58 | the generated migration code will use that rather than the default 59 | `Ecto.Migration`: 60 | 61 | config :ecto_sql, migration_module: MyApplication.CustomMigrationModule 62 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{config_env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ecto_squash, Mix.Tasks.Ecto.SquashTest.Repo, 4 | priv: "tmp", 5 | database: "squash_test", 6 | username: "postgres", 7 | password: "postgres" 8 | -------------------------------------------------------------------------------- /lib/ecto_squash/postgres.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoSquash.Postgres do 2 | @moduledoc """ 3 | Schema dumping code based on Ecto.Adapters.Postgres with `schema_migrations` 4 | table excluded. 5 | """ 6 | 7 | def structure_dump(default, config) do 8 | table = config[:migration_source] || "schema_migrations" 9 | 10 | with {:ok, versions} <- select_versions(table, config), 11 | {:ok, path} <- pg_dump(default, config, table), 12 | do: append_versions(table, versions, path) 13 | end 14 | 15 | defp select_versions(table, config) do 16 | case run_query(~s[SELECT version FROM public."#{table}" ORDER BY version], config) do 17 | {:ok, %{rows: rows}} -> {:ok, Enum.map(rows, &hd/1)} 18 | {:error, %{postgres: %{code: :undefined_table}}} -> {:ok, []} 19 | {:error, _} = error -> error 20 | end 21 | end 22 | 23 | defp pg_dump(default, config, exclude_schema) do 24 | path = config[:dump_path] || Path.join(default, "structure.sql") 25 | File.mkdir_p!(Path.dirname(path)) 26 | 27 | case run_with_cmd("pg_dump", config, [ 28 | "--file", 29 | path, 30 | "--schema-only", 31 | "--no-acl", 32 | "--no-owner", 33 | "--exclude-schema", 34 | exclude_schema, 35 | config[:database] 36 | ]) do 37 | {_output, 0} -> 38 | {:ok, path} 39 | 40 | {output, _} -> 41 | {:error, output} 42 | end 43 | end 44 | 45 | defp append_versions(_table, [], path) do 46 | {:ok, path} 47 | end 48 | 49 | defp append_versions(table, versions, path) do 50 | sql = Enum.map_join(versions, &~s[INSERT INTO public."#{table}" (version) VALUES (#{&1});\n]) 51 | 52 | File.open!(path, [:append], fn file -> 53 | IO.write(file, sql) 54 | end) 55 | 56 | {:ok, path} 57 | end 58 | 59 | ## Helpers 60 | 61 | defp run_query(sql, opts) do 62 | {:ok, _} = Application.ensure_all_started(:ecto_sql) 63 | {:ok, _} = Application.ensure_all_started(:postgrex) 64 | 65 | opts = 66 | opts 67 | |> Keyword.drop([:name, :log, :pool, :pool_size]) 68 | |> Keyword.put(:backoff_type, :stop) 69 | |> Keyword.put(:max_restarts, 0) 70 | 71 | task = 72 | Task.Supervisor.async_nolink(Ecto.Adapters.SQL.StorageSupervisor, fn -> 73 | {:ok, conn} = Postgrex.start_link(opts) 74 | 75 | value = Postgrex.query(conn, sql, [], opts) 76 | GenServer.stop(conn) 77 | value 78 | end) 79 | 80 | timeout = Keyword.get(opts, :timeout, 15_000) 81 | 82 | case Task.yield(task, timeout) || Task.shutdown(task) do 83 | {:ok, {:ok, result}} -> 84 | {:ok, result} 85 | 86 | {:ok, {:error, error}} -> 87 | {:error, error} 88 | 89 | {:exit, {%{__struct__: struct} = error, _}} 90 | when struct in [Postgrex.Error, DBConnection.Error] -> 91 | {:error, error} 92 | 93 | {:exit, reason} -> 94 | {:error, RuntimeError.exception(Exception.format_exit(reason))} 95 | 96 | nil -> 97 | {:error, RuntimeError.exception("command timed out")} 98 | end 99 | end 100 | 101 | defp run_with_cmd(cmd, opts, opt_args) do 102 | unless System.find_executable(cmd) do 103 | raise "could not find executable `#{cmd}` in path, " <> 104 | "please guarantee it is available before running ecto commands" 105 | end 106 | 107 | env = [{"PGCONNECT_TIMEOUT", "10"}] 108 | 109 | env = 110 | if password = opts[:password] do 111 | [{"PGPASSWORD", password} | env] 112 | else 113 | env 114 | end 115 | 116 | args = [] 117 | args = if username = opts[:username], do: ["-U", username | args], else: args 118 | args = if port = opts[:port], do: ["-p", to_string(port) | args], else: args 119 | 120 | host = opts[:socket_dir] || opts[:hostname] || System.get_env("PGHOST") || "localhost" 121 | 122 | if opts[:socket] do 123 | IO.warn( 124 | ":socket option is ignored when connecting in structure_load/2 and structure_dump/2," <> 125 | " use :socket_dir or :hostname instead" 126 | ) 127 | end 128 | 129 | args = ["--host", host | args] 130 | args = args ++ opt_args 131 | System.cmd(cmd, args, env: env, stderr_to_stdout: true) 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/mix/tasks/ecto.squash.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecto.Squash do 2 | use Mix.Task 3 | 4 | require Logger 5 | import Mix.Generator 6 | import Mix.Ecto 7 | import Mix.EctoSQL 8 | 9 | @shortdoc "Squashes several migrations into one" 10 | 11 | @aliases [ 12 | r: :repo, 13 | t: :to, 14 | y: :yes 15 | ] 16 | 17 | @switches [ 18 | to: :integer, 19 | yes: :boolean, 20 | repo: [:string, :keep], 21 | migrations_path: :string, 22 | no_compile: :boolean, 23 | no_deps_check: :boolean 24 | # XXX: No support for prefix yet. 25 | # prefix: :string, 26 | ] 27 | 28 | @moduledoc """ 29 | Replaces several migrations with a SQL-based migration, which applies schema, 30 | and a second one for making sure that all of the squashed migrations has been 31 | applied and nothing else, before migrating further. 32 | 33 | ## Examples 34 | 35 | Squash migrations upto and including 20210601033528 into a single one: 36 | 37 | mix ecto.squash --to 20210601033528 38 | mix ecto.squash --to 20210601033528 -r Custom.Repo 39 | 40 | The repository must be set under `:ecto_repos` in the 41 | current app configuration or given via the `-r` option. 42 | 43 | SQL migration will have a filename prefixed with timestamp of the latest 44 | migration squashed. That way it won't be applied if squashed migration is 45 | already there. Another generated migration will have a +1 second 46 | timestamp. 47 | 48 | By default, the migration will be generated to the 49 | "priv/YOUR_REPO/migrations" directory of the current application 50 | but it can be configured to be any subdirectory of `priv` by 51 | specifying the `:priv` key under the repository configuration. 52 | 53 | ## Command line options 54 | 55 | * `--to VERSION` - squash migrations upto and including VERSION 56 | * `-y`, `--yes` - migrate to specified version, remove squashed migrations 57 | and migrate to latest version without asking to confirm actions 58 | * `-r REPO`, `--repo REPO` - the REPO to generate migration for 59 | * `--migrations-path PATH` - the PATH to run the migrations from, 60 | defaults to `priv/repo/migrations` 61 | * `--no-compile` - does not compile applications before running 62 | * `--no-deps-check` - does not check dependencies before running 63 | 64 | ## Configuration 65 | 66 | If `:ecto_sql` app configuration specifies a custom migration module, 67 | the generated migration code will use that rather than the default 68 | `Ecto.Migration`: 69 | 70 | config :ecto_sql, migration_module: MyApplication.CustomMigrationModule 71 | 72 | """ 73 | 74 | @impl true 75 | def run(args) do 76 | [repo, opts] = parse_args(args) 77 | 78 | # Start ecto_sql explicitly before as we don't need 79 | # to restart those apps if migrated. 80 | {:ok, _} = Application.ensure_all_started(:ecto_sql) 81 | 82 | ensure_repo(repo, args) 83 | 84 | to = opts[:to] 85 | 86 | migrate_opts = 87 | ["-r", inspect(repo)] ++ 88 | if path = opts[:migrations_path], do: ["--migrations-path", path], else: [] 89 | 90 | if yes?(opts, "Migrate to #{to}? (Mandatory to proceed)") do 91 | migrate_to(repo, migrate_opts, to) 92 | else 93 | Logger.warn("Need to apply migrations to proceed.") 94 | exit(:normal) 95 | end 96 | 97 | migrations = get_migrations(repo) 98 | 99 | path = opts[:migrations_path] || Path.join(source_repo_priv(repo), "migrations") 100 | unless File.dir?(path), do: create_directory(path) 101 | 102 | remove_squashed_migrations(path, migrations, opts) 103 | squash_path = create_squash_migration(path, repo, to) 104 | EctoSquash.Postgres.structure_dump(path, repo.config()) 105 | checker_path = create_checker_migration(path, repo, migrations, to) 106 | 107 | [squash_path, checker_path] 108 | end 109 | 110 | defp parse_args(args) do 111 | repo = 112 | case parse_repo(args) do 113 | [repo] -> 114 | repo 115 | 116 | [repo | _] -> 117 | Mix.raise( 118 | "repo ambiguity: several repos available - " <> 119 | "please specify which repo to use with -r, " <> 120 | "e.g. -r #{inspect(repo)}" 121 | ) 122 | end 123 | 124 | case OptionParser.parse!(args, strict: @switches, aliases: @aliases) do 125 | {opts, []} -> 126 | opts[:to] || 127 | Mix.raise( 128 | "`--to` option is mandatory, which is stupid and hopefully will be fixed, " <> 129 | "got: #{inspect(Enum.join(args, " "))}" 130 | ) 131 | 132 | [repo, opts] 133 | 134 | {_, _} -> 135 | Mix.raise( 136 | "ecto.squash supports no arguments, " <> 137 | "got: #{inspect(Enum.join(args, " "))}" 138 | ) 139 | end 140 | end 141 | 142 | defp yes?(opts, question) do 143 | opts[:yes] || Mix.shell().yes?(question) 144 | end 145 | 146 | defp migrate_to(repo, migrate_opts, to) do 147 | migrate_opts_to = migrate_opts ++ ["--to"] 148 | 149 | # Migrate forward if we're behind. 150 | Mix.Task.run("ecto.migrate", migrate_opts_to ++ [Integer.to_string(to)]) 151 | 152 | # Migrate backwards if we're ahead. 153 | # XXX: ecto.rollback rolls back migration specified with `--to` as well. 154 | # Offset index +1 to keep that migration. 155 | migrations = get_migrations(repo) 156 | index = migrations |> Enum.find_index(fn {_, id, _} -> id == to end) 157 | 158 | case Enum.at(migrations, index + 1) do 159 | {_dir, next_migration_id, _name} -> 160 | Mix.Task.run("ecto.rollback", migrate_opts_to ++ [Integer.to_string(next_migration_id)]) 161 | 162 | # Migration is nil when squashing all migrations. 163 | nil -> 164 | nil 165 | end 166 | end 167 | 168 | defp get_migrations(repo) do 169 | {:ok, migrations, _apps} = 170 | Ecto.Migrator.with_repo(repo, fn repo -> 171 | Ecto.Migrator.migrations(repo) 172 | end) 173 | 174 | migrations 175 | end 176 | 177 | defp migration_module do 178 | case Application.get_env(:ecto_sql, :migration_module, Ecto.Migration) do 179 | migration_module when is_atom(migration_module) -> migration_module 180 | other -> Mix.raise("Expected :migration_module to be a module, got: #{inspect(other)}") 181 | end 182 | end 183 | 184 | defp remove_squashed_migrations(path, migrations, opts) do 185 | rm_list = 186 | migrations 187 | |> Enum.map(fn {_dir, id, _name} -> id end) 188 | |> Enum.filter(fn id -> id <= opts[:to] end) 189 | |> Enum.flat_map(fn id -> Path.wildcard(Path.join(path, "#{id}_*.exs")) end) 190 | 191 | if yes?( 192 | opts, 193 | "Remove squashed migrations upto and including #{opts[:to]} (#{length(rm_list)})?" 194 | ) do 195 | Enum.each(rm_list, fn path -> File.rm!(path) end) 196 | end 197 | end 198 | 199 | defp create_squash_migration(path, repo, to) do 200 | # ID matches that of the last migration squashed to prevent newly created 201 | # migration from being applied, since all migrations it contains 202 | # are already applied. 203 | file = Path.join(path, "#{to}_apply_squashed_migrations.exs") 204 | assigns = [mod: Module.concat([repo, Migrations, SquashMigrations]), repo: repo] 205 | create_file(file, sql_migration_template(assigns)) 206 | file 207 | end 208 | 209 | defp create_checker_migration(path, repo, migrations, to) do 210 | file = Path.join(path, "#{to + 1}_ensure_migrated_squash.exs") 211 | 212 | ids = 213 | migrations 214 | |> Enum.filter(fn {dir, _id, _name} -> dir == :up end) 215 | |> Enum.map(fn {_dir, id, _name} -> id end) 216 | |> Enum.sort() 217 | 218 | assigns = [ 219 | mod: Module.concat([repo, Migrations, EnsureMigratedSquash]), 220 | repo: repo, 221 | migration_ids: ids 222 | ] 223 | 224 | create_file(file, checker_migration_template(assigns)) 225 | file 226 | end 227 | 228 | embed_template(:sql_migration, """ 229 | defmodule <%= inspect @mod %> do 230 | use <%= inspect migration_module() %> 231 | 232 | def up do 233 | repo = <%= inspect @repo %> 234 | {:ok, _path} = repo.__adapter__.structure_load(__DIR__, repo.config()) 235 | end 236 | end 237 | """) 238 | 239 | embed_template(:checker_migration, ~S""" 240 | defmodule <%= inspect @mod %> do 241 | use <%= inspect migration_module() %> 242 | alias Ecto.Migration.SchemaMigration 243 | 244 | def up do 245 | needed_migrations = MapSet.new( 246 | <%= inspect @migration_ids, limit: :infinity, pretty: true %> 247 | ) 248 | repo = <%= inspect @repo %> 249 | # XXX: No support for prefix yet. 250 | {migration_repo, query, all_opts} = SchemaMigration.versions(repo, repo.config(), nil) 251 | has_migrations = migration_repo.all(query, all_opts) 252 | |> MapSet.new() 253 | if needed_migrations != has_migrations do 254 | raise "Missing migrations: #{inspect MapSet.difference(needed_migrations, has_migrations)} 255 | extra migrations: #{inspect MapSet.difference(has_migrations, needed_migrations)}" 256 | end 257 | end 258 | 259 | def down do 260 | end 261 | end 262 | """) 263 | end 264 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoSquash.MixProject do 2 | use Mix.Project 3 | 4 | @github_url "https://github.com/prosapient/ecto_squash" 5 | 6 | def project do 7 | [ 8 | app: :ecto_squash, 9 | description: "A Mix task intended to streamline migration squashing.", 10 | version: "0.1.1", 11 | elixir: "~> 1.11", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | source_url: @github_url, 15 | package: package() 16 | ] 17 | end 18 | 19 | # Run "mix help compile.app" to learn about applications. 20 | def application do 21 | [ 22 | extra_applications: [:logger] 23 | ] 24 | end 25 | 26 | # Run "mix help deps" to learn about dependencies. 27 | defp deps do 28 | [ 29 | {:ecto_sql, ">= 3.6.2"}, 30 | {:postgrex, ">= 0.16.0", optional: true}, 31 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 32 | ] 33 | end 34 | 35 | defp package() do 36 | [ 37 | links: %{"GitHub" => @github_url}, 38 | licenses: ["Apache-2.0"] 39 | ] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 3 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 4 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, 6 | "ecto": {:hex, :ecto, "3.10.1", "c6757101880e90acc6125b095853176a02da8f1afe056f91f1f90b80c9389822", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2ac4255f1601bdf7ac74c0ed971102c6829dc158719b94bd30041bbad77f87a"}, 7 | "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, 8 | "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [: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", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, 9 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 13 | "postgrex": {:hex, :postgrex, "0.17.1", "01c29fd1205940ee55f7addb8f1dc25618ca63a8817e56fac4f6846fc2cddcbe", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "14b057b488e73be2beee508fb1955d8db90d6485c6466428fe9ccf1d6692a555"}, 14 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 15 | } 16 | -------------------------------------------------------------------------------- /test/fixtures/20210505120132_apply_squashed_migrations.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecto.SquashTest.Repo.Migrations.SquashMigrations do 2 | use Ecto.Migration 3 | 4 | def up do 5 | repo = Mix.Tasks.Ecto.SquashTest.Repo 6 | {:ok, _path} = repo.__adapter__.structure_load(__DIR__, repo.config()) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/20210505120133_ensure_migrated_squash.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecto.SquashTest.Repo.Migrations.EnsureMigratedSquash do 2 | use Ecto.Migration 3 | alias Ecto.Migration.SchemaMigration 4 | 5 | def up do 6 | needed_migrations = MapSet.new( 7 | [20180103194816, 20180122130454, 20180122130942, 20210505120132] 8 | ) 9 | repo = Mix.Tasks.Ecto.SquashTest.Repo 10 | # XXX: No support for prefix yet. 11 | {migration_repo, query, all_opts} = SchemaMigration.versions(repo, repo.config(), nil) 12 | has_migrations = migration_repo.all(query, all_opts) 13 | |> MapSet.new() 14 | if needed_migrations != has_migrations do 15 | raise "Missing migrations: #{inspect MapSet.difference(needed_migrations, has_migrations)} 16 | extra migrations: #{inspect MapSet.difference(has_migrations, needed_migrations)}" 17 | end 18 | end 19 | 20 | def down do 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/fixtures/migrations/20180103194816_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Pt.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :first_name, :string 7 | add :last_name, :string 8 | 9 | timestamps() 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/migrations/20180122130454_add_phone_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Pt.Repo.Migrations.AddPhoneToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :phone, :string 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/migrations/20180122130942_create_teams.exs: -------------------------------------------------------------------------------- 1 | defmodule Pt.Repo.Migrations.CreateTeams do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:teams) do 6 | add :name, :string 7 | 8 | timestamps() 9 | end 10 | 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/fixtures/migrations/20210505120132_drop_teams.exs: -------------------------------------------------------------------------------- 1 | defmodule Pt.Repo.Migrations.DropEducations do 2 | use Ecto.Migration 3 | 4 | def up do 5 | drop_if_exists table("teams") 6 | end 7 | 8 | def down, do: nil 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/migrations/20220122130942_create_teams.exs: -------------------------------------------------------------------------------- 1 | defmodule Pt.Repo.Migrations.CreateTeams do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:teams) do 6 | add :name, :string 7 | 8 | timestamps() 9 | end 10 | 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/fixtures/structure.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | -- Dumped from database version 11. (Debian 11.8-1.pgdg90+1) 6 | -- Dumped by pg_dump version 13. (Debian 13.3-1.pgdg100+1) 7 | 8 | SET statement_timeout = 0; 9 | SET lock_timeout = 0; 10 | SET idle_in_transaction_session_timeout = 0; 11 | SET client_encoding = 'UTF8'; 12 | SET standard_conforming_strings = on; 13 | SELECT pg_catalog.set_config('search_path', '', false); 14 | SET check_function_bodies = false; 15 | SET xmloption = content; 16 | SET client_min_messages = warning; 17 | SET row_security = off; 18 | 19 | SET default_tablespace = ''; 20 | 21 | -- 22 | -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - 23 | -- 24 | 25 | CREATE TABLE public.schema_migrations ( 26 | version bigint NOT NULL, 27 | inserted_at timestamp(0) without time zone 28 | ); 29 | 30 | 31 | -- 32 | -- Name: users; Type: TABLE; Schema: public; Owner: - 33 | -- 34 | 35 | CREATE TABLE public.users ( 36 | id bigint NOT NULL, 37 | first_name character varying(255), 38 | last_name character varying(255), 39 | inserted_at timestamp(0) without time zone NOT NULL, 40 | updated_at timestamp(0) without time zone NOT NULL, 41 | phone character varying(255) 42 | ); 43 | 44 | 45 | -- 46 | -- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - 47 | -- 48 | 49 | CREATE SEQUENCE public.users_id_seq 50 | START WITH 1 51 | INCREMENT BY 1 52 | NO MINVALUE 53 | NO MAXVALUE 54 | CACHE 1; 55 | 56 | 57 | -- 58 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 59 | -- 60 | 61 | ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; 62 | 63 | 64 | -- 65 | -- Name: users id; Type: DEFAULT; Schema: public; Owner: - 66 | -- 67 | 68 | ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); 69 | 70 | 71 | -- 72 | -- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - 73 | -- 74 | 75 | ALTER TABLE ONLY public.schema_migrations 76 | ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); 77 | 78 | 79 | -- 80 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - 81 | -- 82 | 83 | ALTER TABLE ONLY public.users 84 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 85 | 86 | 87 | -- 88 | -- PostgreSQL database dump complete 89 | -- 90 | 91 | INSERT INTO public."schema_migrations" (version) VALUES (20180103194816); 92 | INSERT INTO public."schema_migrations" (version) VALUES (20180122130454); 93 | INSERT INTO public."schema_migrations" (version) VALUES (20180122130942); 94 | INSERT INTO public."schema_migrations" (version) VALUES (20210505120132); 95 | -------------------------------------------------------------------------------- /test/mix/tasks/ecto.squash_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ecto.SquashTest do 2 | use ExUnit.Case 3 | 4 | import Support.FileHelpers 5 | import Mix.Tasks.Ecto.Squash, only: [run: 1] 6 | 7 | @fixtures __DIR__ <> "/../../fixtures/" 8 | @migrations_path "#{tmp_path()}/migrations" 9 | 10 | defmodule Repo do 11 | use Ecto.Repo, 12 | otp_app: :ecto_squash, 13 | adapter: Ecto.Adapters.Postgres 14 | end 15 | 16 | setup do 17 | Mix.Task.run("ecto.create", ["-r", to_string(Repo)]) 18 | File.rm_rf!(unquote(tmp_path())) 19 | File.mkdir_p!(@migrations_path) 20 | File.cp_r!(@fixtures <> "migrations", @migrations_path) 21 | :ok 22 | end 23 | 24 | test "generates new migrations" do 25 | [squash, checker] = run(["-y", "-r", to_string(Repo), "--to", "20210505120132"]) 26 | assert Path.dirname(squash) == Path.dirname(checker) 27 | assert Path.dirname(checker) == @migrations_path 28 | 29 | assert_file(squash, fn file -> 30 | assert file == fixture("20210505120132_apply_squashed_migrations.exs") 31 | end) 32 | 33 | assert_file(checker, fn file -> 34 | assert file == fixture("20210505120133_ensure_migrated_squash.exs") 35 | end) 36 | 37 | # Skip dump header containing tool/DB versions. 38 | data_range = 6..-1 39 | 40 | dump_data = 41 | File.stream!(Path.dirname(squash) <> "/structure.sql") 42 | |> Enum.slice(data_range) 43 | 44 | fixture_data = 45 | File.stream!(@fixtures <> "/structure.sql") 46 | |> Enum.slice(data_range) 47 | 48 | assert dump_data == fixture_data 49 | end 50 | 51 | defp fixture(path) do 52 | File.read!(@fixtures <> path) 53 | end 54 | 55 | test "custom migrations_path" do 56 | dir = Path.join([tmp_path(), "custom_migrations"]) 57 | File.mkdir_p!(dir) 58 | 59 | [path, _path] = 60 | run(["-y", "-r", to_string(Repo), "--migrations-path", dir, "--to", "20210505120132"]) 61 | 62 | assert Path.dirname(path) == dir 63 | end 64 | 65 | test "raises when missing mandatory option `--to`" do 66 | assert_raise Mix.Error, fn -> run(["-r", to_string(Repo)]) end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/support/file_helpers.exs: -------------------------------------------------------------------------------- 1 | defmodule Support.FileHelpers do 2 | import ExUnit.Assertions 3 | 4 | @doc """ 5 | Returns the `tmp_path` for tests. 6 | """ 7 | def tmp_path do 8 | Path.expand("../../tmp", __DIR__) 9 | end 10 | 11 | @doc """ 12 | Executes the given function in a temp directory 13 | tailored for this test case and test. 14 | """ 15 | defmacro in_tmp(fun) do 16 | path = Path.join([tmp_path(), "#{__CALLER__.module}", "#{elem(__CALLER__.function, 0)}"]) 17 | 18 | quote do 19 | path = unquote(path) 20 | File.rm_rf!(path) 21 | File.mkdir_p!(path) 22 | File.cd!(path, fn -> unquote(fun).(path) end) 23 | end 24 | end 25 | 26 | @doc """ 27 | Asserts a file was generated. 28 | """ 29 | def assert_file(file) do 30 | assert File.regular?(file), "Expected #{file} to exist, but does not" 31 | end 32 | 33 | @doc """ 34 | Asserts a file was generated and that it matches a given pattern. 35 | """ 36 | def assert_file(file, callback) when is_function(callback, 1) do 37 | assert_file(file) 38 | callback.(File.read!(file)) 39 | end 40 | 41 | def assert_file(file, match) do 42 | assert_file(file, &assert(&1 =~ match)) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("./support/file_helpers.exs", __DIR__) 2 | 3 | ExUnit.start() 4 | --------------------------------------------------------------------------------