diff --git a/lib/multi_tenancy.ex b/lib/multi_tenancy.ex new file mode 100644 index 0000000..7e9d206 --- /dev/null +++ b/lib/multi_tenancy.ex @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancy do + @moduledoc """ + A `c:AshSqlite.Repo.tenant_repo/2` implementation that gives each tenant its own database file. + + A tenant's file is opened and migrated on first use, and the repo's `init/2` sees the tenant as `config[:tenant]`. + Only `dir` is required. `max_children` defaults to `:infinity` and `open_timeout`, in milliseconds, to 15000. + + ```elixir + config :my_app, MyApp.Repo, + tenants: [ + dir: "priv/tenants", + migrations_path: "priv/repo/tenant_migrations", + max_children: 1_000, + open_timeout: 15_000 + ] + + defmodule MyApp.Repo do + use AshSqlite.Repo, otp_app: :my_app + + def tenant_repo(tenant, context), + do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context) + end + ``` + """ + + alias AshSqlite.MultiTenancy.{Supervisor, Tenant} + + @unreserved ~c"abcdefghijklmnopqrstuvwxyz0123456789-_." + + # 255 is the usual per-name limit. `-journal` is the longest suffix SQLite adds to + # a database's name, and `.db` is ours. + @max_encoded_bytes 255 - byte_size(".db") - byte_size("-journal") + + @attempts 3 + + @doc "The repo instance serving `tenant`, opening its database if it is not open." + @spec tenant_repo(module(), term(), map()) :: pid() + def tenant_repo(repo, tenant, _context) do + registry = Supervisor.registry(repo) + + if is_nil(Process.whereis(registry)) do + raise ArgumentError, """ + #{inspect(repo)} has no tenant supervisor running, so tenant #{inspect(tenant)} \ + has no database to open. Configure the `tenants:` key and start the repo: + + config :my_app, #{inspect(repo)}, tenants: [dir: "priv/tenants"] + """ + end + + {:ok, dir} = Registry.meta(registry, :dir) + {:ok, open_timeout} = Registry.meta(registry, :open_timeout) + path = Path.join(dir, encode(tenant) <> ".db") + + instance(repo, tenant, path, open_timeout, @attempts) + end + + defp instance(_repo, tenant, _path, _open_timeout, 0) do + raise "tenant #{inspect(tenant)} kept stopping while being opened" + end + + defp instance(repo, tenant, path, open_timeout, attempts) do + pid = start_tenant(repo, tenant, path) + + case Tenant.instance(pid, open_timeout) do + {:ok, instance} -> + instance + + # `:stopped` arrives only after that tenant has exited, and registering a `:via` + # name replaces a dead pid's entry, so the next start registers a new tenant. + :stopped -> + instance(repo, tenant, path, open_timeout, attempts - 1) + + {:timeout, pid} -> + DynamicSupervisor.terminate_child(Supervisor.tenants(repo), pid) + + raise "the database of tenant #{inspect(tenant)} did not open within #{open_timeout}ms. \ + Raise `tenants: [open_timeout: ...]` if opening it legitimately takes longer." + + {:error, reason} -> + raise "could not open the database of tenant #{inspect(tenant)}: #{Exception.format_exit(reason)}" + end + end + + defp start_tenant(repo, tenant, path) do + case DynamicSupervisor.start_child(Supervisor.tenants(repo), {Tenant, {repo, tenant, path}}) do + {:ok, pid} -> + pid + + # May be a tenant that is stopping; `instance/5` waits for it and retries. + {:error, {:already_started, pid}} -> + pid + + {:error, :max_children} -> + raise "could not open tenant #{inspect(tenant)}: #{inspect(repo)} already has the \ + most tenants open that `tenants: [max_children: ...]` allows." + + {:error, reason} -> + raise "could not start tenant #{inspect(tenant)}: #{inspect(reason)}" + end + end + + # Escapes rather than sanitises, so no two tenants share a file and no tenant names a + # path outside `dir`. + defp encode(tenant) when is_binary(tenant) and byte_size(tenant) > 0 do + encoded = tenant |> :binary.bin_to_list() |> Enum.map_join(&encode_byte/1) + + if byte_size(encoded) > @max_encoded_bytes do + raise ArgumentError, """ + tenant #{inspect(tenant)} encodes to #{byte_size(encoded)} bytes, and a \ + database name can be at most #{@max_encoded_bytes}. Use a shorter identifier, \ + such as a slug or a UUID, as the tenant. + """ + end + + encoded + end + + defp encode(tenant) do + raise ArgumentError, """ + expected a non-empty binary tenant, got: #{inspect(tenant)} + + A tenant names a database file, and stringifying another term here would let two \ + tenants that Ash tells apart name one file. Convert it where you set the tenant. + """ + end + + defp encode_byte(byte) when byte in @unreserved, do: <> + + defp encode_byte(byte) do + <> = <> + <> + end + + # Lowercase, so two encodings never differ only by case. + defp hex(nibble) when nibble < 10, do: ?0 + nibble + defp hex(nibble), do: ?a + nibble - 10 +end diff --git a/lib/multi_tenancy/supervisor.ex b/lib/multi_tenancy/supervisor.ex new file mode 100644 index 0000000..79d49c1 --- /dev/null +++ b/lib/multi_tenancy/supervisor.ex @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancy.Supervisor do + @moduledoc false + use Supervisor + + def child_spec(repo, config, ecto_child_spec) do + case Keyword.fetch(config, :tenants) do + :error -> + ecto_child_spec + + {:ok, tenants} -> + if !AshSqlite.DataLayer.tenant_repo?(repo) do + raise ArgumentError, """ + #{inspect(repo)} is configured with `tenants:`, but does not implement \ + `c:AshSqlite.Repo.tenant_repo/2`. Add this to the repo: + + def tenant_repo(tenant, context), do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context) + """ + end + + base_repo = if config[:database], do: [ecto_child_spec], else: [] + + %{ + id: repo, + start: {__MODULE__, :start_link, [{repo, tenants, base_repo}]}, + type: :supervisor + } + end + end + + def start_link({repo, _tenants, _base_repo} = arg) do + Supervisor.start_link(__MODULE__, arg, name: Module.concat(repo, TenantSupervisor)) + end + + def registry(repo), do: Module.concat(repo, TenantRegistry) + def tenants(repo), do: Module.concat(repo, Tenants) + + @impl true + def init({repo, tenants, base_repo}) do + migrations = compile_migrations(tenants[:migrations_path]) + + children = + [ + {Registry, + keys: :unique, + name: registry(repo), + meta: [ + migrations: migrations, + dir: Keyword.fetch!(tenants, :dir), + open_timeout: Keyword.get(tenants, :open_timeout, 15_000) + ]}, + {DynamicSupervisor, + name: tenants(repo), max_children: Keyword.get(tenants, :max_children, :infinity)} + ] ++ base_repo + + # A restarted Registry has forgotten every tenant, so the tenants started after it + # must go too, or a second process could open the same file. + Supervisor.init(children, strategy: :rest_for_one) + end + + defp compile_migrations(nil), do: [] + + # Mirrors `Ecto.Migrator`'s naming rules: `_.exs`. + defp compile_migrations(path) do + [path, "**", "*.exs"] + |> Path.join() + |> Path.wildcard() + |> Enum.flat_map(fn file -> + case Integer.parse(Path.basename(file)) do + {version, "_" <> _} -> [{version, migration_module!(file)}] + _ -> [] + end + end) + |> Enum.sort() + end + + # A restart, or a second repo sharing the path, reuses the modules already loaded + # rather than redefining code a running migration may be executing. + defp migration_module!(file) do + modules = + case Code.require_file(file) do + nil -> modules_compiled_from(file) + compiled -> Enum.map(compiled, &elem(&1, 0)) + end + + Enum.find(modules, &function_exported?(&1, :__migration__, 0)) || + raise Ecto.MigrationError, + "file #{Path.relative_to_cwd(file)} does not define an Ecto.Migration" + end + + defp modules_compiled_from(file) do + source = file |> Path.expand() |> String.to_charlist() + + for {module, _} <- :code.all_loaded(), + module.module_info(:compile)[:source] == source, + do: module + end +end diff --git a/lib/multi_tenancy/tenant.ex b/lib/multi_tenancy/tenant.ex new file mode 100644 index 0000000..b1e9671 --- /dev/null +++ b/lib/multi_tenancy/tenant.ex @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancy.Tenant do + @moduledoc """ + The process that owns one tenant's database: it opens and migrates the file, and its repo instance lives and dies with it. + """ + use GenServer, restart: :temporary, shutdown: 30_000 + + def start_link({repo, tenant, path}) do + GenServer.start_link(__MODULE__, {repo, tenant, path}, + name: {:via, Registry, {AshSqlite.MultiTenancy.Supervisor.registry(repo), tenant}} + ) + end + + @doc """ + The repo instance of the tenant process `pid`, waiting up to `timeout` for its database to open. + + `:stopped` means the process has exited. + """ + @spec instance(pid(), timeout()) :: + {:ok, pid()} | :stopped | {:timeout, pid()} | {:error, term()} + def instance(pid, timeout) do + {:ok, GenServer.call(pid, :instance, timeout)} + catch + :exit, {reason, {GenServer, :call, _}} + when reason in [:noproc, :normal, :shutdown, :killed] -> + :stopped + + :exit, {{:shutdown, _}, {GenServer, :call, _}} -> + :stopped + + :exit, {:timeout, {GenServer, :call, _}} -> + {:timeout, pid} + + :exit, reason -> + {:error, reason} + end + + @impl true + # `DynamicSupervisor.start_child/2` runs `init/1` inside the supervisor, so opening + # here would queue every tenant behind every other tenant's open. + def init({repo, tenant, path}) do + # Trapped so `terminate/2` can stop the instance before this process leaves the + # Registry; otherwise a successor could open the file while the old pool still holds it. + Process.flag(:trap_exit, true) + {:ok, %{repo: repo, tenant: tenant, path: path, instance: nil}, {:continue, :open}} + end + + @impl true + def handle_continue(:open, state) do + registry = AshSqlite.MultiTenancy.Supervisor.registry(state.repo) + {:ok, migrations} = Registry.meta(registry, :migrations) + File.mkdir_p!(Path.dirname(state.path)) + + {:ok, instance} = state.repo.start_link(name: nil, database: state.path, tenant: state.tenant) + state = %{state | instance: instance} + + Ecto.Migrator.run(state.repo, migrations, :up, all: true, dynamic_repo: instance, log: false) + Ecto.Adapters.SQL.query!(instance, "SELECT 1", []) + + {:noreply, state} + end + + @impl true + def handle_call(:instance, _from, state), do: {:reply, state.instance, state} + + @impl true + def handle_info({:EXIT, instance, reason}, %{instance: instance} = state) do + {:stop, reason, %{state | instance: nil}} + end + + def handle_info({:EXIT, _pid, _reason}, state), do: {:noreply, state} + + @impl true + def terminate(_reason, %{instance: instance}) when is_pid(instance) do + Supervisor.stop(instance, :shutdown) + catch + :exit, _ -> :ok + end + + def terminate(_reason, _state), do: :ok +end diff --git a/lib/repo.ex b/lib/repo.ex index 821e967..69c85e9 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -20,6 +20,10 @@ defmodule AshSqlite.Repo do - `:tenant_migrations_path` - The path where your tenant migrations are stored (only relevant for a multitenant implementation) - `:snapshots_path` - The path where the resource snapshots for the migration generator are stored. + + In the repo's application config, as it is read before `init/2` runs: + + - `:tenants` - Opens one database file per tenant. See `AshSqlite.MultiTenancy` for its options. """ @doc "Use this to inform the data layer about what extensions are installed" @@ -41,6 +45,9 @@ defmodule AshSqlite.Repo do instance. Every resource on a tenant repo must use `strategy :context`, so put tables shared across tenants on a repo module of their own. + `AshSqlite.MultiTenancy.tenant_repo/3` is a default implementation that opens + one database file per tenant. + `context.type` is `:read` for queries and aggregates, and `:mutate` for writes and for opening a transaction. """ @@ -65,11 +72,22 @@ defmodule AshSqlite.Repo do adapter: adapter, otp_app: otp_app + defoverridable child_spec: 1 + @behaviour AshSqlite.Repo + @ash_sqlite_otp_app otp_app @before_compile AshSqlite.Repo.BeforeCompile defoverridable insert: 2, insert: 1, insert!: 2, insert!: 1 + def child_spec(opts) do + AshSqlite.MultiTenancy.Supervisor.child_spec( + __MODULE__, + Keyword.merge(Application.get_env(@ash_sqlite_otp_app, __MODULE__, []), opts), + super(Keyword.delete(opts, :tenants)) + ) + end + def installed_extensions, do: [] def migrations_path, do: nil def write_transactions?, do: false @@ -78,6 +96,7 @@ defmodule AshSqlite.Repo do def init(_, config) do new_config = config + |> Keyword.drop([:tenant, :tenants]) |> Keyword.put(:installed_extensions, installed_extensions()) |> Keyword.put(:migrations_path, migrations_path()) |> Keyword.put(:case_sensitive_like, :on) diff --git a/test/multi_tenancy/migrations/20260101000000_create_posts.exs b/test/multi_tenancy/migrations/20260101000000_create_posts.exs new file mode 100644 index 0000000..bf152a0 --- /dev/null +++ b/test/multi_tenancy/migrations/20260101000000_create_posts.exs @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.MultiTenancyMigrations.CreatePosts do + use Ecto.Migration + + def change do + create table(:posts, primary_key: false) do + add(:id, :binary_id, primary_key: true) + add(:title, :text) + end + end +end diff --git a/test/multi_tenancy/multi_tenancy_test.exs b/test/multi_tenancy/multi_tenancy_test.exs new file mode 100644 index 0000000..93e7241 --- /dev/null +++ b/test/multi_tenancy/multi_tenancy_test.exs @@ -0,0 +1,280 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.MultiTenancyTest do + @moduledoc """ + Tenants opened by `AshSqlite.MultiTenancy`, checked against the files on disk. + """ + use ExUnit.Case, async: false + + @moduletag :capture_log + + alias AshSqlite.Test.{MultiTenancyPost, MultiTenancyRepo} + + @migrations Path.expand("migrations", __DIR__) + + defmodule NoCallbackRepo do + @moduledoc false + use AshSqlite.Repo, otp_app: :ash_sqlite + + def min_sqlite_version, do: %Version{major: 3, minor: 38, patch: 0} + end + + setup do + dir = Path.join(System.tmp_dir!(), "ash_sqlite_mt_#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(dir) end) + %{dir: dir} + end + + defp start_repo(dir, tenants \\ []) do + start_supervised!( + {MultiTenancyRepo, + tenants: Keyword.merge([dir: dir, migrations_path: @migrations], tenants)} + ) + end + + defp create!(tenant, title) do + MultiTenancyPost + |> Ash.Changeset.for_create(:create, %{title: title}, tenant: tenant) + |> Ash.create!() + end + + defp titles(tenant) do + MultiTenancyPost |> Ash.read!(tenant: tenant) |> Enum.map(& &1.title) |> Enum.sort() + end + + defp titles_in_file(path) do + {:ok, db} = Exqlite.Sqlite3.open(path) + {:ok, stmt} = Exqlite.Sqlite3.prepare(db, "SELECT title FROM posts ORDER BY title") + {:ok, rows} = Exqlite.Sqlite3.fetch_all(db, stmt) + :ok = Exqlite.Sqlite3.close(db) + List.flatten(rows) + end + + defp db_files(dir), + do: + dir + |> File.ls!() + |> Enum.map(&Path.join(dir, &1)) + |> Enum.filter(&String.ends_with?(&1, ".db")) + + test "a repo without tenants: gets Ecto's child spec" do + assert AshSqlite.TestRepo.child_spec([]) == %{ + id: AshSqlite.TestRepo, + start: {AshSqlite.TestRepo, :start_link, [[]]}, + type: :supervisor + } + end + + test "tenants: without tenant_repo/2 raises at boot", %{dir: dir} do + assert_raise ArgumentError, + ~r/`tenants:`.*\n.*AshSqlite.MultiTenancy.tenant_repo\(__MODULE__/s, + fn -> + NoCallbackRepo.child_spec(tenants: [dir: dir]) + end + end + + test "using the helper with no tenants: config raises a named error" do + assert_raise Ash.Error.Unknown, ~r/no tenant supervisor running.*config :my_app/s, fn -> + create!("acme", "one") + end + end + + test "an Ash create and read go to each tenant's own file", %{dir: dir} do + start_repo(dir) + + create!("acme", "a1") + create!("globex", "g1") + + assert titles("acme") == ["a1"] + assert titles("globex") == ["g1"] + assert titles_in_file(Path.join(dir, "acme.db")) == ["a1"] + assert titles_in_file(Path.join(dir, "globex.db")) == ["g1"] + end + + test "tenants that sanitising would merge get different files", %{dir: dir} do + start_repo(dir) + + create!("a:b", "colon") + create!("a_b", "underscore") + create!("../../etc/x", "traversal") + + files = db_files(dir) + assert length(files) == 3 + assert Enum.all?(files, &(Path.dirname(&1) == dir)) + assert titles("a:b") == ["colon"] + assert titles("a_b") == ["underscore"] + end + + test "concurrent first use of a cold tenant starts one process and one instance", %{dir: dir} do + start_repo(dir) + + instances = + 1..20 + |> Enum.map(fn _ -> + Task.async(fn -> MultiTenancyRepo.tenant_repo("cold", %{}) end) + end) + |> Task.await_many() + + assert [_] = Enum.uniq(instances) + assert [{_, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "cold") + assert %{active: 1} = DynamicSupervisor.count_children(MultiTenancyRepo.Tenants) + end + + test "a migration that raises fails the open", %{dir: dir} do + migrations = Path.join(dir, "migrations") + File.mkdir_p!(migrations) + + File.write!(Path.join(migrations, "20260101000000_boom.exs"), """ + defmodule AshSqlite.Test.MultiTenancyMigrations.Boom do + use Ecto.Migration + def up, do: raise("boom") + end + """) + + start_repo(Path.join(dir, "tenants"), migrations_path: migrations) + + assert_raise RuntimeError, ~r/could not open the database of tenant "acme".*boom/s, fn -> + MultiTenancyRepo.tenant_repo("acme", %{}) + end + end + + test "killing the repo supervisor takes tenants and instances with it", %{dir: dir} do + supervisor = start_repo(dir) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + refs = Enum.map([supervisor, tenant, instance], &Process.monitor/1) + Process.exit(supervisor, :kill) + + for ref <- refs, do: assert_receive({:DOWN, ^ref, _, _, _}) + end + + test "tenants that differ only by case get different files", %{dir: dir} do + start_repo(dir) + + create!("Acme", "upper") + create!("acme", "lower") + + assert length(db_files(dir)) == 2 + assert titles("Acme") == ["upper"] + end + + test "a bad tenant is refused in the caller, without starting a process", %{dir: dir} do + start_repo(dir) + + assert_raise ArgumentError, ~r/expected a non-empty binary tenant/, fn -> + MultiTenancyRepo.tenant_repo(1, %{}) + end + + assert_raise ArgumentError, ~r/encodes to/, fn -> + MultiTenancyRepo.tenant_repo(String.duplicate("A", 100), %{}) + end + + assert %{active: 0} = DynamicSupervisor.count_children(MultiTenancyRepo.Tenants) + end + + test "a caller that finds a dead tenant still in the Registry opens a new one", %{dir: dir} do + start_repo(dir) + old_instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + Process.exit(tenant, :kill) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + + assert instance != old_instance + assert Process.alive?(instance) + end + + test "stopping a tenant stops its instance before it returns", %{dir: dir} do + start_repo(dir) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + :ok = DynamicSupervisor.terminate_child(MultiTenancyRepo.Tenants, tenant) + + refute Process.alive?(instance) + end + + test "a tenant whose instance crashes stops too", %{dir: dir} do + start_repo(dir) + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + ref = Process.monitor(tenant) + + Process.exit(instance, :kill) + + assert_receive {:DOWN, ^ref, _, _, _} + end + + test "an open that outlasts open_timeout raises and stops the tenant", %{dir: dir} do + migrations = Path.join(dir, "migrations") + File.mkdir_p!(migrations) + + File.write!(Path.join(migrations, "20260101000000_slow.exs"), """ + defmodule AshSqlite.Test.MultiTenancyMigrations.Slow do + use Ecto.Migration + def up, do: Process.sleep(1_000) + end + """) + + start_repo(Path.join(dir, "tenants"), migrations_path: migrations, open_timeout: 50) + + assert_raise RuntimeError, ~r/did not open within 50ms/, fn -> + MultiTenancyRepo.tenant_repo("acme", %{}) + end + + assert %{active: 0} = DynamicSupervisor.count_children(MultiTenancyRepo.Tenants) + end + + test "reaching max_children names the option", %{dir: dir} do + start_repo(dir, max_children: 1) + MultiTenancyRepo.tenant_repo("acme", %{}) + + assert_raise RuntimeError, ~r/max_children/, fn -> + MultiTenancyRepo.tenant_repo("globex", %{}) + end + end + + test "restarting the repo does not recompile the migrations", %{dir: dir} do + start_repo(dir) + stop_supervised!(MultiTenancyRepo) + + stderr = ExUnit.CaptureIO.capture_io(:stderr, fn -> start_repo(dir) end) + + refute stderr =~ "redefining module" + MultiTenancyRepo.tenant_repo("acme", %{}) + end + + test "tenants: is read from the application env, not from init/2", %{dir: dir} do + Application.put_env(:ash_sqlite, MultiTenancyRepo, tenants: [dir: dir]) + on_exit(fn -> Application.delete_env(:ash_sqlite, MultiTenancyRepo) end) + + assert %{start: {AshSqlite.MultiTenancy.Supervisor, :start_link, _}} = + MultiTenancyRepo.child_spec([]) + end + + test "init/2 keeps tenant and tenants away from the adapter" do + {:ok, config} = MultiTenancyRepo.init(:runtime, tenant: "acme", tenants: [dir: "x"]) + + refute Keyword.has_key?(config, :tenant) + refute Keyword.has_key?(config, :tenants) + end + + test "a caller arriving while a tenant stops waits for it, then opens a new one", %{dir: dir} do + start_repo(dir) + old_instance = MultiTenancyRepo.tenant_repo("acme", %{}) + [{tenant, _}] = Registry.lookup(MultiTenancyRepo.TenantRegistry, "acme") + + stopping = + Task.async(fn -> DynamicSupervisor.terminate_child(MultiTenancyRepo.Tenants, tenant) end) + + instance = MultiTenancyRepo.tenant_repo("acme", %{}) + Task.await(stopping) + + assert instance != old_instance + assert Process.alive?(instance) + refute Process.alive?(old_instance) + end +end diff --git a/test/support/domain.ex b/test/support/domain.ex index e5d8914..185c7c2 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -21,6 +21,7 @@ defmodule AshSqlite.Test.Domain do resource(AshSqlite.Test.TransactionalAccount) resource(AshSqlite.Test.NamedFnRepoAccount) resource(AshSqlite.Test.TenantedPost) + resource(AshSqlite.Test.MultiTenancyPost) resource(AshSqlite.Test.Organization) resource(AshSqlite.Test.Manager) resource(AshSqlite.Test.Device) diff --git a/test/support/multi_tenancy_repo.ex b/test/support/multi_tenancy_repo.ex new file mode 100644 index 0000000..97d64f4 --- /dev/null +++ b/test/support/multi_tenancy_repo.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.MultiTenancyRepo do + @moduledoc """ + A tenant repo whose tenants are managed by `AshSqlite.MultiTenancy`. + """ + use AshSqlite.Repo, otp_app: :ash_sqlite + + def min_sqlite_version, do: %Version{major: 3, minor: 38, patch: 0} + + def write_transactions?, do: true + + def tenant_repo(tenant, context), + do: AshSqlite.MultiTenancy.tenant_repo(__MODULE__, tenant, context) +end diff --git a/test/support/resources/multi_tenancy_post.ex b/test/support/resources/multi_tenancy_post.ex new file mode 100644 index 0000000..38bbc98 --- /dev/null +++ b/test/support/resources/multi_tenancy_post.ex @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2023 ash_sqlite contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshSqlite.Test.MultiTenancyPost do + @moduledoc """ + A `strategy :context` resource on a repo whose tenants `AshSqlite.MultiTenancy` opens. + """ + use Ash.Resource, domain: AshSqlite.Test.Domain, data_layer: AshSqlite.DataLayer + + actions do + default_accept(:*) + defaults([:create, :read]) + end + + multitenancy do + strategy(:context) + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + end + + sqlite do + table("posts") + repo(AshSqlite.Test.MultiTenancyRepo) + migrate?(false) + end +end