From cc1e2634754c43e41cb1ca5dd7f7422f6040edcf Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Wed, 5 May 2021 02:07:41 +0300 Subject: [PATCH 01/10] depend on information schema for mysql --- README.md | 13 --- config/test.exs | 1 + lib/mix/tasks/triplex.migrate.ex | 2 +- lib/mix/tasks/triplex.mysql.install.ex | 67 ---------------- lib/mix/tasks/triplex.rollback.ex | 2 +- lib/triplex.ex | 79 ++++++++----------- lib/triplex/config.ex | 11 ++- test/mix/tasks/triplex.mysql.install_test.exs | 68 ---------------- 8 files changed, 42 insertions(+), 201 deletions(-) delete mode 100644 lib/mix/tasks/triplex.mysql.install.ex delete mode 100644 test/mix/tasks/triplex.mysql.install_test.exs diff --git a/README.md b/README.md index 43330b2..6d738e3 100644 --- a/README.md +++ b/README.md @@ -40,19 +40,6 @@ Configure the Repo you will use to execute the database commands with: config :triplex, repo: ExampleApp.Repo -### Additional configuration for MySQL - -In MySQL, each tenant will have its own MySQL database. -Triplex uses a table called `tenants` in the main Repo to keep track of the different tenants. -Generate the migration that will create the table by running: - - mix triplex.mysql.install - -And then create the table: - - mix ecto.migrate - - ## Usage Here is a quick overview of what you can do with triplex! diff --git a/config/test.exs b/config/test.exs index 0c6daa7..e8aa247 100644 --- a/config/test.exs +++ b/config/test.exs @@ -9,6 +9,7 @@ config :triplex, "security", "app", "staging", + "triplex_test", ~r/^db\d+$/ ] diff --git a/lib/mix/tasks/triplex.migrate.ex b/lib/mix/tasks/triplex.migrate.ex index a9c7347..8a3ffa2 100644 --- a/lib/mix/tasks/triplex.migrate.ex +++ b/lib/mix/tasks/triplex.migrate.ex @@ -61,7 +61,7 @@ defmodule Mix.Tasks.Triplex.Migrate do * `--log-sql` - log the raw sql migrations are running * `--strict-version-order` - abort when applying a migration with old timestamp * `--no-compile` - does not compile applications before migrating - * `--no-deps-check` - does not check depedendencies before migrating + * `--no-deps-check` - does not check dependencies before migrating ## PS diff --git a/lib/mix/tasks/triplex.mysql.install.ex b/lib/mix/tasks/triplex.mysql.install.ex deleted file mode 100644 index 1aa0a02..0000000 --- a/lib/mix/tasks/triplex.mysql.install.ex +++ /dev/null @@ -1,67 +0,0 @@ -defmodule Mix.Tasks.Triplex.Mysql.Install do - @moduledoc """ - Generates a migration to create the tenant table - in the default database (MySQL only). - """ - - use Mix.Task - - require Mix.Generator - - alias Ecto.Adapters.MyXQL - alias Ecto.Migrator - alias Mix.Ecto - alias Mix.Generator - alias Mix.Project - - @migration_name "create_tenant" - - @shortdoc "Generates a migration for the tenant table in the default database" - - @doc false - def run(args) do - Ecto.no_umbrella!("ecto.gen.migration") - repos = Ecto.parse_repo(args) - - Enum.each(repos, fn repo -> - Ecto.ensure_repo(repo, args) - - if repo.__adapter__ != MyXQL do - Mix.raise("the tenant table only makes sense for MySQL repositories") - end - - path = Path.relative_to(Migrator.migrations_path(repo), Project.app_path()) - file = Path.join(path, "#{timestamp()}_#{@migration_name}.exs") - Generator.create_directory(path) - - Generator.create_file( - file, - migration_template( - repo: repo, - migration_name: @migration_name, - tenant_table: Triplex.config().tenant_table - ) - ) - end) - end - - defp timestamp do - {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() - "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" - end - - defp pad(i), do: i |> to_string() |> String.pad_leading(2, "0") - - Generator.embed_template(:migration, """ - defmodule <%= Module.concat([@repo, Migrations, Macro.camelize(@migration_name)]) %> do - use Ecto.Migration - - def change do - create table(:<%= @tenant_table %>) do - add :name, :string - end - create unique_index(:<%= @tenant_table %>, [:name]) - end - end - """) -end diff --git a/lib/mix/tasks/triplex.rollback.ex b/lib/mix/tasks/triplex.rollback.ex index 7fc4cff..a4442e7 100644 --- a/lib/mix/tasks/triplex.rollback.ex +++ b/lib/mix/tasks/triplex.rollback.ex @@ -57,7 +57,7 @@ defmodule Mix.Tasks.Triplex.Rollback do * `--pool-size` - the pool size if the repository is started only for the task (defaults to 1) * `--log-sql` - log the raw sql migrations are running * `--no-compile` - does not compile applications before rolling back - * `--no-deps-check` - does not check depedendencies before rolling back + * `--no-deps-check` - does not check dependencies before rolling back ## PS diff --git a/lib/triplex.ex b/lib/triplex.ex index bc132b2..de2ae5e 100644 --- a/lib/triplex.ex +++ b/lib/triplex.ex @@ -2,7 +2,7 @@ defmodule Triplex do @moduledoc """ This is the main module of Triplex. - The main objetive of it is to make a little bit easier to manage tenants + The main objective of it is to make a little bit easier to manage tenants through postgres db schemas or equivalents, executing queries and commands inside and outside the tenant without much boilerplate code. @@ -15,7 +15,7 @@ defmodule Triplex do Repo.all(User, prefix: Triplex.to_prefix("my_tenant")) - It's a good idea to call `Triplex.to_prefix` on your tenant name, altough is + It's a good idea to call `Triplex.to_prefix` on your tenant name, although is not required. Because, if you configured a `tenant_prefix`, this function will return the prefixed one. """ @@ -30,7 +30,7 @@ defmodule Triplex do def config, do: struct(Triplex.Config, Application.get_all_env(:triplex)) @doc """ - Returns the list of reserverd tenants. + Returns the list of reserved tenants. By default, there are some limitations for the name of a tenant depending on the database, like "public" or anything that start with "pg_". @@ -47,6 +47,9 @@ defmodule Triplex do nil, "public", "information_schema", + "performance_schema", + "sys", + "mysql", ~r/^pg_/ | config().reserved_tenants ] @@ -137,7 +140,7 @@ defmodule Triplex do After creating it successfully, the given `func` callback is called with the `tenant` and the `repo` as arguments. The `func` must return - `{:ok, any}` if successfull or `{:error, reason}` otherwise. In the case + `{:ok, any}` if successful or `{:error, reason}` otherwise. In the case the `func` fails, this func will rollback the created schema and fail with the same `reason`. @@ -147,18 +150,24 @@ defmodule Triplex do if reserved_tenant?(tenant) do {:error, reserved_message(tenant)} else + charset = config().opts[:charset] + collate = config().opts[:collate] + sql = case repo.__adapter__ do - Ecto.Adapters.MyXQL -> "CREATE DATABASE #{to_prefix(tenant)}" - Ecto.Adapters.Postgres -> "CREATE SCHEMA \"#{to_prefix(tenant)}\"" + Ecto.Adapters.MyXQL -> + "CREATE DATABASE #{to_prefix(tenant)} DEFAULT CHARSET #{charset} COLLATE #{collate}" + + Ecto.Adapters.Postgres -> + "CREATE SCHEMA \"#{to_prefix(tenant)}\"" end case SQL.query(repo, sql, []) do {:ok, _} -> - with {:ok, _} <- add_to_tenants_table(tenant, repo), - {:ok, _} <- exec_func(func, tenant, repo) do - {:ok, tenant} - else + case exec_func(func, tenant, repo) do + {:ok, _} -> + {:ok, tenant} + {:error, reason} -> drop(tenant, repo) {:error, error_message(reason)} @@ -178,27 +187,6 @@ defmodule Triplex do end end - defp add_to_tenants_table(tenant, repo) do - case repo.__adapter__ do - Ecto.Adapters.MyXQL -> - sql = "INSERT INTO #{Triplex.config().tenant_table} (name) VALUES (?)" - SQL.query(repo, sql, [tenant]) - - Ecto.Adapters.Postgres -> - {:ok, :skipped} - end - end - - defp remove_from_tenants_table(tenant, repo) do - case repo.__adapter__ do - Ecto.Adapters.MyXQL -> - SQL.query(repo, "DELETE FROM #{Triplex.config().tenant_table} WHERE NAME = ?", [tenant]) - - Ecto.Adapters.Postgres -> - {:ok, :skipped} - end - end - defp exec_func(nil, tenant, _) do {:ok, tenant} end @@ -227,10 +215,10 @@ defmodule Triplex do Ecto.Adapters.Postgres -> "DROP SCHEMA \"#{to_prefix(tenant)}\" CASCADE" end - with {:ok, _} <- SQL.query(repo, sql, []), - {:ok, _} <- remove_from_tenants_table(tenant, repo) do - {:ok, tenant} - else + case SQL.query(repo, sql, []) do + {:ok, _} -> + {:ok, tenant} + {:error, exception} -> {:error, error_message(exception)} end @@ -274,17 +262,10 @@ defmodule Triplex do Returns all the tenants on the given `repo`. """ def all(repo \\ config().repo) do - sql = - case repo.__adapter__ do - Ecto.Adapters.MyXQL -> - "SELECT name FROM #{config().tenant_table}" - - Ecto.Adapters.Postgres -> - """ - SELECT schema_name - FROM information_schema.schemata - """ - end + sql = """ + SELECT schema_name + FROM information_schema.schemata + """ %{rows: result} = SQL.query!(repo, sql, []) @@ -305,7 +286,11 @@ defmodule Triplex do sql = case repo.__adapter__ do Ecto.Adapters.MyXQL -> - "SELECT COUNT(*) FROM #{config().tenant_table} WHERE name = ?" + """ + SELECT COUNT(*) + FROM information_schema.schemata + WHERE schema_name = ? + """ Ecto.Adapters.Postgres -> """ diff --git a/lib/triplex/config.ex b/lib/triplex/config.ex index 7b781ac..75b8c33 100644 --- a/lib/triplex/config.ex +++ b/lib/triplex/config.ex @@ -5,9 +5,9 @@ defmodule Triplex.Config do - `repo`: the ecto repo that will be used to execute the schema operations. - `tenant_prefix`: a prefix for all tenants. - `reserved_tenants`: a list of reserved tenants, which cannot be created - thourhg triplex APIs. The items here can be strings or regexes. - - `tenant_field`: an atom with the name of the field to get the tenant name - if the given tenant is a struct. By default it's `:id`. + through triplex APIs. The items here can be strings or regexes. + - `opts`: extra options to supply for the create database query for MySQL driver + supported options are `charset` and `collate`. """ defstruct [ @@ -16,6 +16,9 @@ defmodule Triplex.Config do migrations_path: "tenant_migrations", reserved_tenants: [], tenant_field: :id, - tenant_table: :tenants + opts: [ + charset: "utf8mb4", + collate: "utf8mb4_bin" + ] ] end diff --git a/test/mix/tasks/triplex.mysql.install_test.exs b/test/mix/tasks/triplex.mysql.install_test.exs deleted file mode 100644 index 98e9db6..0000000 --- a/test/mix/tasks/triplex.mysql.install_test.exs +++ /dev/null @@ -1,68 +0,0 @@ -defmodule Mix.Tasks.Triplex.Mysql.InstallTest do - use ExUnit.Case, async: true - - import Support.FileHelpers - - alias Mix.Tasks.Triplex.Mysql.Install - - tmp_path = Path.join(tmp_path(), inspect(Install)) - @migrations_path Path.join(tmp_path, "migrations") - - defmodule MySQLRepo do - def __adapter__ do - Ecto.Adapters.MyXQL - end - - def config do - [priv: "tmp/#{inspect(Install)}", otp_app: :triplex] - end - end - - defmodule PGRepo do - def __adapter__ do - Ecto.Adapters.Postgres - end - - def config do - [priv: "tmp/#{inspect(Triplex.MySQL.Install)}", otp_app: :triplex] - end - end - - setup do - File.rm_rf!(unquote(tmp_path)) - - Mix.shell(Mix.Shell.Process) - - on_exit(fn -> - Mix.shell(Mix.Shell.IO) - end) - - :ok - end - - test "generates a migration to install mysql" do - Install.run(["-r", to_string(MySQLRepo)]) - assert [name] = File.ls!(@migrations_path) - assert String.match?(name, ~r/^\d{14}_create_tenant\.exs$/) - - assert_file(Path.join(@migrations_path, name), fn file -> - assert file =~ """ - defmodule Elixir.Mix.Tasks.Triplex.Mysql.InstallTest.MySQLRepo.Migrations.CreateTenant do - """ - - assert file =~ "use Ecto.Migration" - assert file =~ "def change do" - assert file =~ "create table(:tenants) do" - assert file =~ "add :name, :string" - assert file =~ "create unique_index(:tenants, [:name])" - end) - end - - test "raises an exception for non mysql repos" do - msg = "the tenant table only makes sense for MySQL repositories" - - assert_raise Mix.Error, msg, fn -> - Install.run(["-r", to_string(PGRepo)]) - end - end -end From 8c6fef865e466f06c77d693961d9227663230139 Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Wed, 5 May 2021 11:56:39 +0300 Subject: [PATCH 02/10] better test --- lib/mix/tasks/triplex.gen.migration.ex | 2 +- lib/triplex/plugs/session_plug.ex | 2 +- lib/triplex/plugs/subdomain_plug.ex | 2 +- test/mix/triplex_test.exs | 2 +- test/triplex/triplex_test.exs | 3 ++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/mix/tasks/triplex.gen.migration.ex b/lib/mix/tasks/triplex.gen.migration.ex index db64670..0710d2e 100644 --- a/lib/mix/tasks/triplex.gen.migration.ex +++ b/lib/mix/tasks/triplex.gen.migration.ex @@ -3,8 +3,8 @@ defmodule Mix.Tasks.Triplex.Gen.Migration do require Mix.Generator - alias Mix.Project alias Mix.Generator + alias Mix.Project @shortdoc "Generates a new tenant migration for the repo" diff --git a/lib/triplex/plugs/session_plug.ex b/lib/triplex/plugs/session_plug.ex index 1e7efd1..7baccb5 100644 --- a/lib/triplex/plugs/session_plug.ex +++ b/lib/triplex/plugs/session_plug.ex @@ -15,8 +15,8 @@ if Code.ensure_loaded?(Plug) do alias Plug.Conn - alias Triplex.SessionPlugConfig alias Triplex.Plug + alias Triplex.SessionPlugConfig @doc false def init(opts), do: struct(SessionPlugConfig, opts) diff --git a/lib/triplex/plugs/subdomain_plug.ex b/lib/triplex/plugs/subdomain_plug.ex index 249b83b..7edc225 100644 --- a/lib/triplex/plugs/subdomain_plug.ex +++ b/lib/triplex/plugs/subdomain_plug.ex @@ -15,8 +15,8 @@ if Code.ensure_loaded?(Plug) do alias Plug.Conn - alias Triplex.SubdomainPlugConfig alias Triplex.Plug + alias Triplex.SubdomainPlugConfig @doc false def init(opts), do: struct(SubdomainPlugConfig, opts) diff --git a/test/mix/triplex_test.exs b/test/mix/triplex_test.exs index 448c87c..764f988 100644 --- a/test/mix/triplex_test.exs +++ b/test/mix/triplex_test.exs @@ -1,5 +1,5 @@ defmodule Mix.TriplexTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false @repos [Triplex.PGTestRepo, Triplex.MSTestRepo] diff --git a/test/triplex/triplex_test.exs b/test/triplex/triplex_test.exs index d96f824..0c79e25 100644 --- a/test/triplex/triplex_test.exs +++ b/test/triplex/triplex_test.exs @@ -91,7 +91,8 @@ defmodule TriplexTest do Triplex.create("lala", repo) Triplex.create("lili", repo) Triplex.create("lolo", repo) - assert MapSet.new(Triplex.all(repo)) == MapSet.new(["lala", "lili", "lolo"]) + + assert MapSet.subset?(MapSet.new(["lala", "lili", "lolo"]), MapSet.new(Triplex.all(repo))) end end From 27503e39f6fe723235eb38cb170315edf8f7233d Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Wed, 5 May 2021 12:14:59 +0300 Subject: [PATCH 03/10] add travis to reserved tenants --- config/test.exs | 1 + test/mix/triplex_test.exs | 2 +- test/triplex/triplex_test.exs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/config/test.exs b/config/test.exs index e8aa247..60053d9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -10,6 +10,7 @@ config :triplex, "app", "staging", "triplex_test", + "travis", ~r/^db\d+$/ ] diff --git a/test/mix/triplex_test.exs b/test/mix/triplex_test.exs index 764f988..448c87c 100644 --- a/test/mix/triplex_test.exs +++ b/test/mix/triplex_test.exs @@ -1,5 +1,5 @@ defmodule Mix.TriplexTest do - use ExUnit.Case, async: false + use ExUnit.Case, async: true @repos [Triplex.PGTestRepo, Triplex.MSTestRepo] diff --git a/test/triplex/triplex_test.exs b/test/triplex/triplex_test.exs index 0c79e25..07fbb5f 100644 --- a/test/triplex/triplex_test.exs +++ b/test/triplex/triplex_test.exs @@ -92,7 +92,7 @@ defmodule TriplexTest do Triplex.create("lili", repo) Triplex.create("lolo", repo) - assert MapSet.subset?(MapSet.new(["lala", "lili", "lolo"]), MapSet.new(Triplex.all(repo))) + assert MapSet.new(Triplex.all(repo)) == MapSet.new(["lala", "lili", "lolo"]) end end From df9b642ffc4cf9f0910cf5b1de488ed2dffb052f Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Mon, 10 May 2021 14:20:28 +0300 Subject: [PATCH 04/10] secure database name for mysql --- lib/triplex.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/triplex.ex b/lib/triplex.ex index de2ae5e..f8b012f 100644 --- a/lib/triplex.ex +++ b/lib/triplex.ex @@ -156,7 +156,7 @@ defmodule Triplex do sql = case repo.__adapter__ do Ecto.Adapters.MyXQL -> - "CREATE DATABASE #{to_prefix(tenant)} DEFAULT CHARSET #{charset} COLLATE #{collate}" + "CREATE DATABASE `#{to_prefix(tenant)}` DEFAULT CHARSET #{charset} COLLATE #{collate}" Ecto.Adapters.Postgres -> "CREATE SCHEMA \"#{to_prefix(tenant)}\"" From c3f4518098551365674a4263a1cc6d6d269753a8 Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Tue, 11 May 2021 11:33:56 +0300 Subject: [PATCH 05/10] secure database name for mysql on drop --- lib/triplex.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/triplex.ex b/lib/triplex.ex index f8b012f..d53a806 100644 --- a/lib/triplex.ex +++ b/lib/triplex.ex @@ -211,7 +211,7 @@ defmodule Triplex do else sql = case repo.__adapter__ do - Ecto.Adapters.MyXQL -> "DROP DATABASE #{to_prefix(tenant)}" + Ecto.Adapters.MyXQL -> "DROP DATABASE `#{to_prefix(tenant)}`" Ecto.Adapters.Postgres -> "DROP SCHEMA \"#{to_prefix(tenant)}\" CASCADE" end From d9071dbbd845f117cf2e9c6a8c787ff9ff0fe641 Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Fri, 14 May 2021 19:11:47 +0300 Subject: [PATCH 06/10] rename opts to mysql opts --- lib/triplex.ex | 4 ++-- lib/triplex/config.ex | 5 +++-- lib/triplex/plugs/subdomain_plug_config.ex | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/triplex.ex b/lib/triplex.ex index d53a806..5decf59 100644 --- a/lib/triplex.ex +++ b/lib/triplex.ex @@ -150,8 +150,8 @@ defmodule Triplex do if reserved_tenant?(tenant) do {:error, reserved_message(tenant)} else - charset = config().opts[:charset] - collate = config().opts[:collate] + charset = config().mysql[:charset] + collate = config().mysql[:collate] sql = case repo.__adapter__ do diff --git a/lib/triplex/config.ex b/lib/triplex/config.ex index 75b8c33..3356384 100644 --- a/lib/triplex/config.ex +++ b/lib/triplex/config.ex @@ -6,7 +6,8 @@ defmodule Triplex.Config do - `tenant_prefix`: a prefix for all tenants. - `reserved_tenants`: a list of reserved tenants, which cannot be created through triplex APIs. The items here can be strings or regexes. - - `opts`: extra options to supply for the create database query for MySQL driver + - `mysql`: extra options to supply for the create database query for MySQL driver. + The SQL standard allows a DEFAULT CHARACTER SET clause in CREATE SCHEMA than are presently accepted by PostgreSQL. supported options are `charset` and `collate`. """ @@ -16,7 +17,7 @@ defmodule Triplex.Config do migrations_path: "tenant_migrations", reserved_tenants: [], tenant_field: :id, - opts: [ + mysql: [ charset: "utf8mb4", collate: "utf8mb4_bin" ] diff --git a/lib/triplex/plugs/subdomain_plug_config.ex b/lib/triplex/plugs/subdomain_plug_config.ex index 08784c5..2c6a43e 100644 --- a/lib/triplex/plugs/subdomain_plug_config.ex +++ b/lib/triplex/plugs/subdomain_plug_config.ex @@ -7,7 +7,7 @@ defmodule Triplex.SubdomainPlugConfig do - `tenant_handler`: function to handle the tenant param. Its return will be used as the tenant. - `assign`: the name of the assign where we must save the tenant. - - `endpoint`: the Phoenix.Endpoint to get the host name to dicover the + - `endpoint`: the Phoenix.Endpoint to get the host name to discover the subdomain. """ From b28909f176028ddd9d2ef3d2899a76e89c4cf4ba Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Fri, 14 May 2021 20:33:00 +0300 Subject: [PATCH 07/10] backward compatability for tenants table --- README.md | 20 +++++- config/test.exs | 2 + lib/mix/tasks/triplex.mysql.install.ex | 66 +++++++++++++++++ lib/triplex.ex | 71 ++++++++++++++----- lib/triplex/config.ex | 3 + test/mix/tasks/triplex.mysql.install_test.exs | 68 ++++++++++++++++++ test/triplex/triplex_test.exs | 1 - 7 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 lib/mix/tasks/triplex.mysql.install.ex create mode 100644 test/mix/tasks/triplex.mysql.install_test.exs diff --git a/README.md b/README.md index 6d738e3..eab0c16 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,30 @@ Configure the Repo you will use to execute the database commands with: config :triplex, repo: ExampleApp.Repo +### Additional configuration for MySQL + +In MySQL, each tenant will have its own MySQL database. +Triplex used to use a table called `tenants` in the main Repo to keep track of the different tenants. +If you wish to keep this behavior, generate the migration that will create the table by running: + + mix triplex.mysql.install + +And then create the table: + + mix ecto.migrate + +Finally, configure Triplex to use the `tenants` table: + + config :triplex, tenant_table: :tenants + +Otherwise, Triplex will continue to use the `information_schema.schemata` table as the default behavior for storing tenants. + ## Usage Here is a quick overview of what you can do with triplex! -### Creating, renaming and droping tenants +### Creating, renaming and dropping tenants #### To create a new tenant: diff --git a/config/test.exs b/config/test.exs index 60053d9..daf61e9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,6 +14,8 @@ config :triplex, ~r/^db\d+$/ ] +config :triplex, tenant_table: :tenants + # Configure your database config :triplex, ecto_repos: [Triplex.PGTestRepo, Triplex.MSTestRepo] diff --git a/lib/mix/tasks/triplex.mysql.install.ex b/lib/mix/tasks/triplex.mysql.install.ex new file mode 100644 index 0000000..345d3b4 --- /dev/null +++ b/lib/mix/tasks/triplex.mysql.install.ex @@ -0,0 +1,66 @@ +defmodule Mix.Tasks.Triplex.Mysql.Install do + @moduledoc """ + Generates a migration to create the tenant table + in the default database (MySQL only). + """ + + use Mix.Task + + require Mix.Generator + + alias Ecto.Adapters.MyXQL + alias Ecto.Migrator + alias Mix.Ecto + alias Mix.Generator + alias Mix.Project + + @migration_name "create_tenant" + + @shortdoc "Generates a migration for the tenant table in the default database" + + @doc false + def run(args) do + Ecto.no_umbrella!("ecto.gen.migration") + repos = Ecto.parse_repo(args) + + Enum.each(repos, fn repo -> + Ecto.ensure_repo(repo, args) + + if repo.__adapter__ != MyXQL do + Mix.raise("the tenant table only makes sense for MySQL repositories") + end + + path = Path.relative_to(Migrator.migrations_path(repo), Project.app_path()) + file = Path.join(path, "#{timestamp()}_#{@migration_name}.exs") + Generator.create_directory(path) + + Generator.create_file( + file, + migration_template( + repo: repo, + migration_name: @migration_name, + tenant_table: Triplex.config().tenant_table + ) + ) + end) + end + + defp timestamp do + {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() + "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" + end + + defp pad(i), do: i |> to_string() |> String.pad_leading(2, "0") + + Generator.embed_template(:migration, """ + defmodule <%= Module.concat([@repo, Migrations, Macro.camelize(@migration_name)]) %> do + use Ecto.Migration + def change do + create table(:<%= @tenant_table %>) do + add :name, :string + end + create unique_index(:<%= @tenant_table %>, [:name]) + end + end + """) +end diff --git a/lib/triplex.ex b/lib/triplex.ex index 5decf59..97eaaae 100644 --- a/lib/triplex.ex +++ b/lib/triplex.ex @@ -164,10 +164,10 @@ defmodule Triplex do case SQL.query(repo, sql, []) do {:ok, _} -> - case exec_func(func, tenant, repo) do - {:ok, _} -> - {:ok, tenant} - + with {:ok, _} <- add_to_tenants_table(tenant, repo), + {:ok, _} <- exec_func(func, tenant, repo) do + {:ok, tenant} + else {:error, reason} -> drop(tenant, repo) {:error, error_message(reason)} @@ -187,6 +187,35 @@ defmodule Triplex do end end + defp add_to_tenants_table(tenant, repo) do + case repo.__adapter__ do + Ecto.Adapters.MyXQL -> + if Triplex.config().tenant_table == :"information_schema.schemata" do + {:ok, :skipped} + else + sql = "INSERT INTO #{Triplex.config().tenant_table} (name) VALUES (?)" + SQL.query(repo, sql, [tenant]) + end + + Ecto.Adapters.Postgres -> + {:ok, :skipped} + end + end + + defp remove_from_tenants_table(tenant, repo) do + case repo.__adapter__ do + Ecto.Adapters.MyXQL -> + if Triplex.config().tenant_table == :"information_schema.schemata" do + {:ok, :skipped} + else + SQL.query(repo, "DELETE FROM #{Triplex.config().tenant_table} WHERE NAME = ?", [tenant]) + end + + Ecto.Adapters.Postgres -> + {:ok, :skipped} + end + end + defp exec_func(nil, tenant, _) do {:ok, tenant} end @@ -215,10 +244,10 @@ defmodule Triplex do Ecto.Adapters.Postgres -> "DROP SCHEMA \"#{to_prefix(tenant)}\" CASCADE" end - case SQL.query(repo, sql, []) do - {:ok, _} -> - {:ok, tenant} - + with {:ok, _} <- SQL.query(repo, sql, []), + {:ok, _} <- remove_from_tenants_table(tenant, repo) do + {:ok, tenant} + else {:error, exception} -> {:error, error_message(exception)} end @@ -262,10 +291,22 @@ defmodule Triplex do Returns all the tenants on the given `repo`. """ def all(repo \\ config().repo) do - sql = """ - SELECT schema_name - FROM information_schema.schemata - """ + sql = + case repo.__adapter__ do + Ecto.Adapters.MyXQL -> + field_name = + if Triplex.config().tenant_table == :"information_schema.schemata", + do: "schema_name", + else: "name" + + "SELECT #{field_name} FROM `#{config().tenant_table}`" + + Ecto.Adapters.Postgres -> + """ + SELECT schema_name + FROM information_schema.schemata + """ + end %{rows: result} = SQL.query!(repo, sql, []) @@ -286,11 +327,7 @@ defmodule Triplex do sql = case repo.__adapter__ do Ecto.Adapters.MyXQL -> - """ - SELECT COUNT(*) - FROM information_schema.schemata - WHERE schema_name = ? - """ + "SELECT COUNT(*) FROM `#{config().tenant_table}` WHERE name = ?" Ecto.Adapters.Postgres -> """ diff --git a/lib/triplex/config.ex b/lib/triplex/config.ex index 3356384..b1a728c 100644 --- a/lib/triplex/config.ex +++ b/lib/triplex/config.ex @@ -6,6 +6,8 @@ defmodule Triplex.Config do - `tenant_prefix`: a prefix for all tenants. - `reserved_tenants`: a list of reserved tenants, which cannot be created through triplex APIs. The items here can be strings or regexes. + - `tenant_field`: an atom with the name of the field to get the tenant name + if the given tenant is a struct. By default it's `:id`. - `mysql`: extra options to supply for the create database query for MySQL driver. The SQL standard allows a DEFAULT CHARACTER SET clause in CREATE SCHEMA than are presently accepted by PostgreSQL. supported options are `charset` and `collate`. @@ -17,6 +19,7 @@ defmodule Triplex.Config do migrations_path: "tenant_migrations", reserved_tenants: [], tenant_field: :id, + tenant_table: :"information_schema.schemata", mysql: [ charset: "utf8mb4", collate: "utf8mb4_bin" diff --git a/test/mix/tasks/triplex.mysql.install_test.exs b/test/mix/tasks/triplex.mysql.install_test.exs new file mode 100644 index 0000000..98e9db6 --- /dev/null +++ b/test/mix/tasks/triplex.mysql.install_test.exs @@ -0,0 +1,68 @@ +defmodule Mix.Tasks.Triplex.Mysql.InstallTest do + use ExUnit.Case, async: true + + import Support.FileHelpers + + alias Mix.Tasks.Triplex.Mysql.Install + + tmp_path = Path.join(tmp_path(), inspect(Install)) + @migrations_path Path.join(tmp_path, "migrations") + + defmodule MySQLRepo do + def __adapter__ do + Ecto.Adapters.MyXQL + end + + def config do + [priv: "tmp/#{inspect(Install)}", otp_app: :triplex] + end + end + + defmodule PGRepo do + def __adapter__ do + Ecto.Adapters.Postgres + end + + def config do + [priv: "tmp/#{inspect(Triplex.MySQL.Install)}", otp_app: :triplex] + end + end + + setup do + File.rm_rf!(unquote(tmp_path)) + + Mix.shell(Mix.Shell.Process) + + on_exit(fn -> + Mix.shell(Mix.Shell.IO) + end) + + :ok + end + + test "generates a migration to install mysql" do + Install.run(["-r", to_string(MySQLRepo)]) + assert [name] = File.ls!(@migrations_path) + assert String.match?(name, ~r/^\d{14}_create_tenant\.exs$/) + + assert_file(Path.join(@migrations_path, name), fn file -> + assert file =~ """ + defmodule Elixir.Mix.Tasks.Triplex.Mysql.InstallTest.MySQLRepo.Migrations.CreateTenant do + """ + + assert file =~ "use Ecto.Migration" + assert file =~ "def change do" + assert file =~ "create table(:tenants) do" + assert file =~ "add :name, :string" + assert file =~ "create unique_index(:tenants, [:name])" + end) + end + + test "raises an exception for non mysql repos" do + msg = "the tenant table only makes sense for MySQL repositories" + + assert_raise Mix.Error, msg, fn -> + Install.run(["-r", to_string(PGRepo)]) + end + end +end diff --git a/test/triplex/triplex_test.exs b/test/triplex/triplex_test.exs index 07fbb5f..d96f824 100644 --- a/test/triplex/triplex_test.exs +++ b/test/triplex/triplex_test.exs @@ -91,7 +91,6 @@ defmodule TriplexTest do Triplex.create("lala", repo) Triplex.create("lili", repo) Triplex.create("lolo", repo) - assert MapSet.new(Triplex.all(repo)) == MapSet.new(["lala", "lili", "lolo"]) end end From c841cc10ea8e7b1c31cec43a3e381a9e6be09bb4 Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Fri, 14 May 2021 21:00:00 +0300 Subject: [PATCH 08/10] set default tenant_table as :tenants --- README.md | 12 +++++------- config/test.exs | 2 -- lib/triplex/config.ex | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index eab0c16..e97d1ce 100644 --- a/README.md +++ b/README.md @@ -43,20 +43,18 @@ Configure the Repo you will use to execute the database commands with: ### Additional configuration for MySQL In MySQL, each tenant will have its own MySQL database. -Triplex used to use a table called `tenants` in the main Repo to keep track of the different tenants. -If you wish to keep this behavior, generate the migration that will create the table by running: +Triplex uses a table called `tenants` in the main Repo to keep track of the different tenants. +Generate the migration that will create the table by running: mix triplex.mysql.install And then create the table: - mix ecto.migrate + mix ecto.migrate -Finally, configure Triplex to use the `tenants` table: +Otherwise, if you wish to skip this behavior, configure Triplex to use the default `information_schema.schemata` table: - config :triplex, tenant_table: :tenants - -Otherwise, Triplex will continue to use the `information_schema.schemata` table as the default behavior for storing tenants. + config :triplex, tenant_table: :"information_schema.schemata" ## Usage diff --git a/config/test.exs b/config/test.exs index daf61e9..60053d9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,8 +14,6 @@ config :triplex, ~r/^db\d+$/ ] -config :triplex, tenant_table: :tenants - # Configure your database config :triplex, ecto_repos: [Triplex.PGTestRepo, Triplex.MSTestRepo] diff --git a/lib/triplex/config.ex b/lib/triplex/config.ex index b1a728c..409d320 100644 --- a/lib/triplex/config.ex +++ b/lib/triplex/config.ex @@ -19,7 +19,7 @@ defmodule Triplex.Config do migrations_path: "tenant_migrations", reserved_tenants: [], tenant_field: :id, - tenant_table: :"information_schema.schemata", + tenant_table: :tenants, mysql: [ charset: "utf8mb4", collate: "utf8mb4_bin" From 41ff67a7234df31e64c65a42844d04d55dc69466 Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Fri, 21 May 2021 17:02:37 +0300 Subject: [PATCH 09/10] drop mysql default character set to be introduced in different pr --- lib/mix/tasks/triplex.mysql.install.ex | 1 + lib/triplex.ex | 14 ++++---------- lib/triplex/config.ex | 9 +-------- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/lib/mix/tasks/triplex.mysql.install.ex b/lib/mix/tasks/triplex.mysql.install.ex index 345d3b4..1aa0a02 100644 --- a/lib/mix/tasks/triplex.mysql.install.ex +++ b/lib/mix/tasks/triplex.mysql.install.ex @@ -55,6 +55,7 @@ defmodule Mix.Tasks.Triplex.Mysql.Install do Generator.embed_template(:migration, """ defmodule <%= Module.concat([@repo, Migrations, Macro.camelize(@migration_name)]) %> do use Ecto.Migration + def change do create table(:<%= @tenant_table %>) do add :name, :string diff --git a/lib/triplex.ex b/lib/triplex.ex index 97eaaae..9c9cd24 100644 --- a/lib/triplex.ex +++ b/lib/triplex.ex @@ -150,16 +150,10 @@ defmodule Triplex do if reserved_tenant?(tenant) do {:error, reserved_message(tenant)} else - charset = config().mysql[:charset] - collate = config().mysql[:collate] - sql = case repo.__adapter__ do - Ecto.Adapters.MyXQL -> - "CREATE DATABASE `#{to_prefix(tenant)}` DEFAULT CHARSET #{charset} COLLATE #{collate}" - - Ecto.Adapters.Postgres -> - "CREATE SCHEMA \"#{to_prefix(tenant)}\"" + Ecto.Adapters.MyXQL -> "CREATE DATABASE `#{to_prefix(tenant)}`" + Ecto.Adapters.Postgres -> "CREATE SCHEMA \"#{to_prefix(tenant)}\"" end case SQL.query(repo, sql, []) do @@ -294,12 +288,12 @@ defmodule Triplex do sql = case repo.__adapter__ do Ecto.Adapters.MyXQL -> - field_name = + column_name = if Triplex.config().tenant_table == :"information_schema.schemata", do: "schema_name", else: "name" - "SELECT #{field_name} FROM `#{config().tenant_table}`" + "SELECT #{column_name} FROM `#{config().tenant_table}`" Ecto.Adapters.Postgres -> """ diff --git a/lib/triplex/config.ex b/lib/triplex/config.ex index 409d320..12f21e1 100644 --- a/lib/triplex/config.ex +++ b/lib/triplex/config.ex @@ -8,9 +8,6 @@ defmodule Triplex.Config do through triplex APIs. The items here can be strings or regexes. - `tenant_field`: an atom with the name of the field to get the tenant name if the given tenant is a struct. By default it's `:id`. - - `mysql`: extra options to supply for the create database query for MySQL driver. - The SQL standard allows a DEFAULT CHARACTER SET clause in CREATE SCHEMA than are presently accepted by PostgreSQL. - supported options are `charset` and `collate`. """ defstruct [ @@ -19,10 +16,6 @@ defmodule Triplex.Config do migrations_path: "tenant_migrations", reserved_tenants: [], tenant_field: :id, - tenant_table: :tenants, - mysql: [ - charset: "utf8mb4", - collate: "utf8mb4_bin" - ] + tenant_table: :tenants ] end From 9bda4dc759f62e454e52abb1ecec6f83534831c3 Mon Sep 17 00:00:00 2001 From: HammamSamara <h.samara.74@gmail.com> Date: Sat, 29 May 2021 02:41:18 +0300 Subject: [PATCH 10/10] use information_schema as the default behaviour --- README.md | 10 ++++++---- config/test.exs | 2 ++ lib/triplex/config.ex | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e97d1ce..d1ba591 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Configure the Repo you will use to execute the database commands with: ### Additional configuration for MySQL In MySQL, each tenant will have its own MySQL database. -Triplex uses a table called `tenants` in the main Repo to keep track of the different tenants. -Generate the migration that will create the table by running: +Triplex used to use a table called `tenants` in the main Repo to keep track of the different tenants. +If you wish to keep this behavior, generate the migration that will create the table by running: mix triplex.mysql.install @@ -52,9 +52,11 @@ And then create the table: mix ecto.migrate -Otherwise, if you wish to skip this behavior, configure Triplex to use the default `information_schema.schemata` table: +Finally, configure Triplex to use the `tenants` table: - config :triplex, tenant_table: :"information_schema.schemata" + config :triplex, tenant_table: :tenants + +Otherwise, Triplex will continue to use the `information_schema.schemata` table as the default behavior for storing tenants. ## Usage diff --git a/config/test.exs b/config/test.exs index 60053d9..daf61e9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,6 +14,8 @@ config :triplex, ~r/^db\d+$/ ] +config :triplex, tenant_table: :tenants + # Configure your database config :triplex, ecto_repos: [Triplex.PGTestRepo, Triplex.MSTestRepo] diff --git a/lib/triplex/config.ex b/lib/triplex/config.ex index 12f21e1..2c0bdc2 100644 --- a/lib/triplex/config.ex +++ b/lib/triplex/config.ex @@ -16,6 +16,6 @@ defmodule Triplex.Config do migrations_path: "tenant_migrations", reserved_tenants: [], tenant_field: :id, - tenant_table: :tenants + tenant_table: :"information_schema.schemata" ] end