# SPDX-FileCopyrightText: 2019 ash_postgres contributors # # SPDX-License-Identifier: MIT defmodule AshPostgres.MultiTenancy do @moduledoc false require Logger # sobelow_skip ["SQL.Query"] def create_tenant!(tenant_name, repo) do validate_tenant_name!(tenant_name) Ecto.Adapters.SQL.query!(repo, "CREATE SCHEMA IF NOT EXISTS \"#{tenant_name}\"", []) migrate_tenant(tenant_name, repo) end def migrate_tenant(tenant_name, repo, migrations_path \\ nil, after_file \\ nil) do tenant_migrations_path = migrations_path || repo.config()[:tenant_migrations_path] || default_tenant_migration_path(repo) Ecto.Migration.SchemaMigration.ensure_schema_migrations_table!( repo, repo.config(), prefix: tenant_name ) [tenant_migrations_path, "**", "*.exs"] |> Path.join() |> Path.wildcard() |> Enum.sort() |> then(fn files -> if after_file do files |> Enum.drop_while(fn file -> file != after_file end) |> Enum.drop(1) else files end end) |> Enum.map(&extract_migration_info/1) |> Enum.filter(& &1) |> Enum.map(&load_migration_with_file!/1) |> then(fn migrations -> if repo.in_transaction?() do modules_requiring_no_transaction = migrations |> Enum.filter(fn {_version, mod, _file} -> migration_requires_no_transaction?(mod) end) |> Enum.map(fn {_version, mod, _file} -> mod end) if Enum.any?(modules_requiring_no_transaction) do module_list = Enum.map_join(modules_requiring_no_transaction, ", ", &inspect/1) Logger.warning(""" Tenant migrations use @disable_ddl_transaction (e.g. CREATE INDEX CONCURRENTLY) but are \ running inside a transaction. This will likely fail with "CREATE INDEX CONCURRENTLY cannot \ run inside a transaction block". Affected modules: #{module_list} To fix this, ensure the action that creates/migrates tenants does not run inside a transaction. \ For Ash resources with manage_tenant: true, set transaction?: false on the create/update action. """) end end migrations end) |> Enum.each(fn {version, mod, _file} -> Ecto.Migration.Runner.run( repo, [], version, mod, :forward, :up, :up, all: true, prefix: tenant_name ) Ecto.Migration.SchemaMigration.up(repo, repo.config(), version, prefix: tenant_name) end) end # sobelow_skip ["SQL"] def rename_tenant(repo, old_name, new_name) do validate_tenant_name!(old_name) validate_tenant_name!(new_name) if to_string(old_name) != to_string(new_name) do Ecto.Adapters.SQL.query(repo, "ALTER SCHEMA \"#{old_name}\" RENAME TO \"#{new_name}\"") end :ok end defp load_migration_with_file!({version, _, file}) when is_binary(file) do loaded_modules = file |> compile_file() |> Enum.map(&elem(&1, 0)) case Enum.find(loaded_modules, &migration?/1) do nil -> raise Ecto.MigrationError, "file #{Path.relative_to_cwd(file)} does not define an Ecto.Migration" mod -> {version, mod, file} end end defp compile_file(file) do AshPostgres.MigrationCompileCache.start_link() AshPostgres.MigrationCompileCache.compile_file(file) end defp migration?(mod) do function_exported?(mod, :__migration__, 0) end defp extract_migration_info(file) do base = Path.basename(file) case Integer.parse(Path.rootname(base)) do {integer, "_" <> name} -> {integer, name, file} _ -> nil end end defp validate_tenant_name!(tenant_name) do if !Regex.match?(tenant_name_regex(), tenant_name) do raise "Tenant name must match #{inspect(tenant_name_regex())}, got: #{tenant_name}" end end defp default_tenant_migration_path(repo) do repo_name = repo |> Module.split() |> List.last() |> Macro.underscore() otp_app = repo.config()[:otp_app] :code.priv_dir(otp_app) |> Path.join(repo_name) |> Path.join("tenant_migrations") end defp tenant_name_regex do ~r/^[a-zA-Z0-9_-]+$/ end # Check if a migration requires no transaction by examining the compiled module's # migration metadata (e.g. @disable_ddl_transaction for CREATE INDEX CONCURRENTLY). # Running such migrations inside a transaction will fail in PostgreSQL. defp migration_requires_no_transaction?(mod) do if function_exported?(mod, :__migration__, 0) do migration_info = mod.__migration__() case migration_info do map when is_map(map) -> Map.get(migration_info, :disable_ddl_transaction, false) keyword when is_list(keyword) -> Keyword.get(migration_info, :disable_ddl_transaction, false) _ -> false end else false end end end