diff --git a/.formatter.exs b/.formatter.exs index 18c9078..b891442 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -3,6 +3,7 @@ spark_locals_without_parens = [ code?: 1, deferrable: 1, down: 1, + enable_write_transactions?: 1, exclusion_constraint_names: 1, foreign_key_names: 1, identity_index_names: 1, diff --git a/README.md b/README.md index c632cb2..d77ad58 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,4 @@ Welcome! `AshSqlite` is the SQLite data layer for [Ash Framework](https://hexdoc ## Reference -- [AshSqlite.DataLayer DSL](documentation/dsls/DSL:-AshSqlite.DataLayer.md) +- [AshSqlite.DataLayer DSL](documentation/dsls/DSL-AshSqlite.DataLayer.md) diff --git a/documentation/dsls/DSL:-AshSqlite.DataLayer.md b/documentation/dsls/DSL-AshSqlite.DataLayer.md similarity index 96% rename from documentation/dsls/DSL:-AshSqlite.DataLayer.md rename to documentation/dsls/DSL-AshSqlite.DataLayer.md index 0d68638..0e88952 100644 --- a/documentation/dsls/DSL:-AshSqlite.DataLayer.md +++ b/documentation/dsls/DSL-AshSqlite.DataLayer.md @@ -35,7 +35,7 @@ end | Name | Type | Default | Docs | |------|------|---------|------| -| [`repo`](#sqlite-repo){: #sqlite-repo .spark-required} | `atom` | | The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more | +| [`repo`](#sqlite-repo){: #sqlite-repo .spark-required} | `module \| (any, any -> any)` | | The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more | | [`migrate?`](#sqlite-migrate?){: #sqlite-migrate? } | `boolean` | `true` | Whether or not to include this resource in the generated migrations with `mix ash.generate_migrations` | | [`migration_types`](#sqlite-migration_types){: #sqlite-migration_types } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration type that should be used for that attribute. Only necessary if you need to override the defaults. | | [`migration_defaults`](#sqlite-migration_defaults){: #sqlite-migration_defaults } | `keyword` | `[]` | A keyword list of attribute names to the ecto migration default that should be used for that attribute. The string you use will be placed verbatim in the migration. Use fragments like `fragment(\\"now()\\")`, or for `nil`, use `\\"nil\\"`. | @@ -48,6 +48,7 @@ end | [`migration_ignore_attributes`](#sqlite-migration_ignore_attributes){: #sqlite-migration_ignore_attributes } | `list(atom)` | `[]` | A list of attributes that will be ignored when generating migrations. | | [`table`](#sqlite-table){: #sqlite-table } | `String.t` | | The table to store and read the resource from. If this is changed, the migration generator will not remove the old table. | | [`polymorphic?`](#sqlite-polymorphic?){: #sqlite-polymorphic? } | `boolean` | `false` | Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more. | +| [`enable_write_transactions?`](#sqlite-enable_write_transactions?){: #sqlite-enable_write_transactions? } | `boolean` | `false` | Enable write transactions for this resource. See the [SQLite transaction guide](/documentation/topics/about-as-sqlite/transactions.md) for more. | ## sqlite.custom_indexes diff --git a/documentation/topics/about-ash-sqlite/transactions.md b/documentation/topics/about-ash-sqlite/transactions.md new file mode 100644 index 0000000..a382393 --- /dev/null +++ b/documentation/topics/about-ash-sqlite/transactions.md @@ -0,0 +1,175 @@ +# Transactions and SQLite + +By default SQLite3 allows only one write transaction to be running at a time. Any attempt to commit a transaction while another is running will result in an error. Because Elixir is a highly concurrent environment and [Ecto](https://hexdocs.pm/ecto) uses a connection pool by default, AshSqlite disables transactions by default. This can lead to some surprising behaviours if you're used to working with AshPostgres - for example after action hooks which fail will leave records behind, but the action which ran them will return an error. This document discusses some strategies for working around this constraint. + +## Replacing transactions with Reactor sagas + +A saga is a way of making transaction-like behaviour by explicitly telling the system to undo any changes it has made up until the point of failure. This works well for remote resources such as web APIs, but also for working with Ash data layers that do not support transactions. As a general rule; anything you could model with action lifecycle hooks can also be modelled with Reactor, with the addition of a bit more ceremony. + +For our example, we'll use the idea of a system where posting a comment needs to increment an engagement score. Here's how you could naively implement it: + +```elixir +defmodule MyApp.Blog.Comment do + use Ash.Resource, + data_layer: AshSqlite.DataLayer, + domain: MyApp.Blog + + attributes do + uuid_primary_key :id + + attribute :body, :string, allow_nil?: false, public?: true + + create_timestamp :inserted_at + end + + + actions do + defaults [:read, :destroy, update: :*] + + create :create do + argument :post_id, :uuid, allow_nil?: false, public?: true + + primary? true + + change manage_relationsip(:post_id, :post, type: :append) + change relate_to_actor(:author) + + change after_action(fn _changeset, record, context -> + context.actor + |> Ash.Changeset.for_update(:increment_engagement) + |> Ash.update(actor: context.actor) + |> case do + {:ok, _} -> {:ok, record} + {:error, reason} -> {:error, reason} + end + end) + end + end + + relationships do + belongs_to :author, MyApp.Blog.User, public?: true, writable?: true + belongs_to :post, MyApp.Blog.Post, public?: true, writable?: true + end +end + +defmodule MyApp.Blog.User do + use Ash.Resource, + data_layer: AshSqlite.DataLayer, + domain: MyApp.Blog + + attributes do + uuid_primary_key :id + + attribute :name, :string, allow_nil?: false, public?: true + attribute :engagement_level, :integer, allow_nil?: false, default: 0, public?: true + + create_timestamp :inserted_at + update_timestamp :updated_at + end + + actions do + defaults [:read, :destroy, create: :*, update: :*] + + update :increment_engagement do + public? true + change increment(:engagement_level, amount: 1) + end + + update :decrement_engagement do + public? true + change increment(:engagement_level, amount: -1) + end + end + + relationships do + has_many :posts, MyApp.Blog.Post, public?: true, destination_attribute: :author_id + has_many :comments, MyApp.Blog.Comment, public?: true, destination_attribute: :author_id + end +end +``` + +This would work as expected - as long as everything goes according to plan - if, however, there is a transient failure, or some kind of validation error in one of the hooks could cause the comment to be created, but the create action to still return an error. + +Converting the create into a Reactor requires us to be explicit about how our steps are composed and what the dependencies between them are: + +```elixir +defmodule MyApp.Blog.Comment do + # ... + + actions do + defaults [:read, :destroy, update: :*, create: :*] + + action :post_comment, :struct do + constraints instance_of: __MODULE__ + argument :body, :string, allow_nil?: false, public?: true + argument :post_id, :uuid, allow_nil?: false, public?: true + argument :author_id, :uuid, allow_nil?: false, public?: true + run MyApp.Blog.PostCommentReactor + end + end + + # ... +end + +defmodule MyApp.Blog.PostCommentReactor do + use Reactor, extensions: [Ash.Reactor] + + input :body + input :post_id + input :author_id + + read_one :get_author, MyApp.Blog.User, :get_by_id do + inputs %{id: input(:author_id)} + fail_on_not_found? true + authorize? false + end + + create :create_comment, MyApp.Blog.Comment, :create do + inputs %{ + body: input(:body), + post_id: input(:post_id), + author_id: input(:author_id) + } + + actor result(:get_author) + undo :always + undo_action :destroy + end + + update :update_author_engagement, MyApp.Blog.User, :increment_engagement do + initial result(:get_author) + actor result(:get_author) + undo :always + undo_action :decrement_engagement + end + + return :create_comment +end +``` + +> {: .neutral} +> Note that the examples above are edited for brevity and will not run with without modification + +## Enabling transactions + +Sometimes you really just want to be able to use a database transaction, in which case you can set `enable_write_transactions?` to `true` in the `sqlite` DSL block: + +```elixir +defmodule MyApp.Blog.Post do + use Ash.Resource, + data_layer: AshSqlite.DataLayer, + domain: MyApp.Blog + + sqlite do + repo MyApp.Repo + enable_write_transactions? true + end +end +``` + +This will allow you to set `transaction? true` on actions. Doing this needs very careful management to ensure that all transactions are serialised. + +Strategies for serialising transactions include: + + - Running all writes through a single `GenServer`. + - Using a separate write repo with a pool size of 1. \ No newline at end of file diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 9048f51..8730a00 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -194,7 +194,7 @@ defmodule AshSqlite.DataLayer do ], schema: [ repo: [ - type: :atom, + type: {:or, [{:behaviour, Ecto.Repo}, {:fun, 2}]}, required: true, doc: "The repo that will be used to fetch your data. See the `AshSqlite.Repo` documentation for more" @@ -278,6 +278,13 @@ defmodule AshSqlite.DataLayer do doc: """ Declares this resource as polymorphic. See the [polymorphic resources guide](/documentation/topics/resources/polymorphic-resources.md) for more. """ + ], + enable_write_transactions?: [ + type: :boolean, + default: false, + doc: """ + Enable write transactions for this resource. See the [SQLite transaction guide](/documentation/topics/about-as-sqlite/transactions.md) for more. + """ ] ] } @@ -296,6 +303,9 @@ defmodule AshSqlite.DataLayer do AshSqlite.Transformers.ValidateReferences, AshSqlite.Transformers.VerifyRepo, AshSqlite.Transformers.EnsureTableOrPolymorphic + ], + verifiers: [ + AshSqlite.Verifiers.VerifyTransactions ] def migrate(args) do @@ -303,6 +313,30 @@ defmodule AshSqlite.DataLayer do Mix.Task.run("ash_sqlite.migrate", args) end + @impl true + def transaction(resource, func, timeout \\ nil, reason \\ %{type: :custom, metadata: %{}}) do + repo = + case reason[:data_layer_context] do + %{repo: repo} when not is_nil(repo) -> + repo + + _ -> + AshSqlite.DataLayer.Info.repo(resource, :read) + end + + func = fn -> + repo.on_transaction_begin(reason) + + func.() + end + + if timeout do + repo.transaction(func, timeout: timeout) + else + repo.transaction(func) + end + end + def rollback(args) do repos = AshSqlite.Mix.Helpers.repos!([], args) @@ -385,7 +419,10 @@ defmodule AshSqlite.DataLayer do def can?(_, :bulk_create), do: true def can?(_, {:lock, _}), do: false - def can?(_, :transact), do: false + def can?(resource, :transact) do + Spark.Dsl.Extension.get_opt(resource, [:sqlite], :enable_write_transactions?, false) + end + def can?(_, :composite_primary_key), do: true def can?(_, {:atomic, :update}), do: true def can?(_, {:atomic, :upsert}), do: true @@ -446,6 +483,11 @@ defmodule AshSqlite.DataLayer do def can?(_, {:sort, _}), do: true def can?(_, _), do: false + @impl true + def in_transaction?(resource) do + AshSqlite.DataLayer.Info.repo(resource, :mutate).in_transaction?() + end + @impl true def limit(query, nil, _), do: {:ok, query} diff --git a/lib/data_layer/info.ex b/lib/data_layer/info.ex index abb5193..4f9ba96 100644 --- a/lib/data_layer/info.ex +++ b/lib/data_layer/info.ex @@ -4,8 +4,14 @@ defmodule AshSqlite.DataLayer.Info do alias Spark.Dsl.Extension @doc "The configured repo for a resource" - def repo(resource) do - Extension.get_opt(resource, [:sqlite], :repo, nil, true) + def repo(resource, type \\ :mutate) do + case Extension.get_opt(resource, [:sqlite], :repo, nil, true) do + fun when is_function(fun, 2) -> + fun.(resource, type) + + repo -> + repo + end end @doc "The configured table for a resource" diff --git a/lib/mix/tasks/ash_sqlite.install.ex b/lib/mix/tasks/ash_sqlite.install.ex index 5762acd..4dade02 100644 --- a/lib/mix/tasks/ash_sqlite.install.ex +++ b/lib/mix/tasks/ash_sqlite.install.ex @@ -27,7 +27,7 @@ defmodule Mix.Tasks.AshSqlite.Install do Igniter.Project.Module.module_name(igniter, "Repo") repo -> - Igniter.Code.Module.parse(repo) + Igniter.Project.Module.parse(repo) end otp_app = Igniter.Project.Application.app_name(igniter) diff --git a/lib/repo.ex b/lib/repo.ex index 765c3fb..346e9e1 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -12,12 +12,8 @@ defmodule AshSqlite.Repo do @doc "Use this to inform the data layer about what extensions are installed" @callback installed_extensions() :: [String.t()] - @doc """ - Use this to inform the data layer about the oldest potential sqlite version it will be run on. - - Must be an integer greater than or equal to 13. - """ - @callback min_pg_version() :: integer() + @doc "Called when a transaction starts" + @callback on_transaction_begin(reason :: Ash.DataLayer.transaction_reason()) :: term @doc "The path where your migrations are stored" @callback migrations_path() :: String.t() | nil @@ -39,7 +35,6 @@ defmodule AshSqlite.Repo do def installed_extensions, do: [] def migrations_path, do: nil def override_migration_type(type), do: type - def min_pg_version, do: 10 def init(_, config) do new_config = @@ -51,6 +46,8 @@ defmodule AshSqlite.Repo do {:ok, new_config} end + def on_transaction_begin(_reason), do: :ok + def insert(struct_or_changeset, opts \\ []) do struct_or_changeset |> to_ecto() @@ -83,6 +80,13 @@ defmodule AshSqlite.Repo do |> from_ecto() end + def transaction!(fun) do + case fun.() do + {:ok, value} -> value + {:error, error} -> raise Ash.Error.to_error_class(error) + end + end + def from_ecto({:ok, result}), do: {:ok, from_ecto(result)} def from_ecto({:error, _} = other), do: other @@ -148,8 +152,8 @@ defmodule AshSqlite.Repo do defoverridable init: 2, installed_extensions: 0, - override_migration_type: 1, - min_pg_version: 0 + on_transaction_begin: 1, + override_migration_type: 1 end end end diff --git a/lib/verifiers/verify_transactions.ex b/lib/verifiers/verify_transactions.ex new file mode 100644 index 0000000..29a8bdb --- /dev/null +++ b/lib/verifiers/verify_transactions.ex @@ -0,0 +1,80 @@ +defmodule AshSqlite.Verifiers.VerifyTransactions do + @moduledoc """ + Verify that transactions are explicitly disabled for write actions when they + are disabled in the configuration. + """ + alias Spark.{Dsl.Verifier, Error.DslError} + @behaviour Spark.Dsl.Verifier + + @doc false + @impl true + def verify(dsl) do + can_transact? = Ash.DataLayer.data_layer_can?(dsl, :transact) + + if can_transact? do + :ok + else + verify_actions(dsl) + end + end + + defp verify_actions(dsl) do + dsl + |> Ash.Resource.Info.actions() + |> Enum.reject(&(&1.type == :read)) + |> Enum.filter(&(&1.transaction? == true)) + |> case do + [] -> + :ok + + [action] -> + module = Verifier.get_persisted(dsl, :module) + + {:error, + DslError.exception( + module: module, + path: [:actions, action.name], + message: message(module, [action]) + )} + + actions -> + module = Verifier.get_persisted(dsl, :module) + + {:error, + DslError.exception( + module: module, + path: [:actions], + message: message(module, actions) + )} + end + end + + defp message(module, actions) do + actions = + actions + |> Enum.map_join("\n", fn action -> + " - #{action.type} `#{action.name}`" + end) + + """ + Transactions are disabled on the `#{inspect(module)}` resource. + + Because of Sqlite3's requirement that only a single write transaction be + occurring at any one time, AshSqlite disables all write transactions by + default. This is to avoid database busy errors for concurrent write + transactions. + + There are two ways to disable this error. Set `transaction? false` on the + following actions: + + #{actions} + + Or set `enable_write_transactions? true` in the `sqlite` DSL block of your + resource, and carefully manage transaction concurrency. + + See the [transaction guide][1] for more information. + + [1]: https://hexdocs.pm/ash_sqlite/transactions.html + """ + end +end diff --git a/mix.exs b/mix.exs index 995f79f..057d584 100644 --- a/mix.exs +++ b/mix.exs @@ -71,13 +71,14 @@ defmodule AshSqlite.MixProject do {"README.md", title: "Home"}, "documentation/tutorials/getting-started-with-ash-sqlite.md", "documentation/topics/about-ash-sqlite/what-is-ash-sqlite.md", + "documentation/topics/about-ash-sqlite/transactions.md", "documentation/topics/resources/references.md", "documentation/topics/resources/polymorphic-resources.md", "documentation/topics/development/migrations-and-tasks.md", "documentation/topics/development/testing.md", "documentation/topics/advanced/expressions.md", "documentation/topics/advanced/manual-relationships.md", - "documentation/dsls/DSL:-AshSqlite.DataLayer.md", + "documentation/dsls/DSL-AshSqlite.DataLayer.md", "CHANGELOG.md" ], groups_for_extras: [ @@ -126,7 +127,7 @@ defmodule AshSqlite.MixProject do {:ecto_sqlite3, "~> 0.12"}, {:ecto, "~> 3.9"}, {:jason, "~> 1.0"}, - {:ash, ash_version("~> 3.1 and >= 3.1.7")}, + {:ash, ash_version("~> 3.1 and >= 3.4.33")}, {:ash_sql, ash_sql_version("~> 0.2 and >= 0.2.20")}, {:igniter, "~> 0.3 and >= 0.3.42"}, {:simple_sat, ">= 0.0.0", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 6bb27be..b0a487b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "ash": {:hex, :ash, "3.4.32", "7445f6876491acecf1621ad97434540a360f82fbfeb7c8c4f7971c92c9ae5f91", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.61 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a20224455a842f792d3aeb1fe313dedf619e4ad3d60a583f863b7417f8dc1991"}, + "ash": {:hex, :ash, "3.4.33", "acae7fb64cab4852488c2491007ed088d7d108805deb70b453d8ee0e7ba95af8", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.61 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ed1f608888f2a03dcc94dad8e4d7ccf91f1250571b7e431b2833cc4125da905"}, "ash_sql": {:hex, :ash_sql, "0.2.36", "e5722123de5b726ad3185ef8c8ce5ef17b78d3409e822cadeadc6ba5110601fe", [:mix], [{:ash, ">= 3.1.7 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "a95b5ebccfe5e74d7fc4e46b104abae4d1003b53cbc8418fcb5fa3c6e0c081a9"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, @@ -19,9 +19,9 @@ "exqlite": {:hex, :exqlite, "0.23.0", "6e851c937a033299d0784994c66da24845415072adbc455a337e20087bce9033", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "404341cceec5e6466aaed160cf0b58be2019b60af82588c215e1224ebd3ec831"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, - "git_ops": {:hex, :git_ops, "2.6.3", "38c6e381b8281b86e2911fa39bea4eab2d171c86d7428786566891efb73b68c3", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a81cb6c6a2a026a4d48cb9a2e1dfca203f9283a3a70aa0c7bc171970c44f23f8"}, - "glob_ex": {:hex, :glob_ex, "0.1.9", "b97a25392f5339e49f587e5b24c468c6a4f38299febd5ec85c5f8bb2e42b5c1e", [:mix], [], "hexpm", "be72e584ad1d8776a4d134d4b6da1bac8b80b515cdadf0120e0920b9978d7f01"}, - "igniter": {:hex, :igniter, "0.3.63", "ac27c466e6f779cf5f39d200a7d433cd91c8d465277e001661dc9b4680ca9eb3", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "5e24f71479cfd3575f79a767db51de0b38a633f05107b05d94ef1a54fde9093f"}, + "git_ops": {:hex, :git_ops, "2.6.2", "1e03e44d1b41e91cf39b47d214d515a0d67d44a7fda13ef131bb70bd706dd398", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fc0200fbc6d2784e2f3b9be200ce63e523d0d9ff527c6308490c3d6d325d5e6f"}, + "glob_ex": {:hex, :glob_ex, "0.1.10", "d819a368637495a5c1962ef34f48fe4e9a09032410b96ade5758f2cd1cc5fcde", [:mix], [], "hexpm", "c75357e57d71c85ef8ef7269b6e787dce3f0ff71e585f79a90e4d5477c532b90"}, + "igniter": {:hex, :igniter, "0.3.67", "e39c10f17ecb06be4e6a969b3278d34419c4faf7224017ad03d07783ebd29f11", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "52c73613f33e2da12a84633fc16d0686d1f648cfecb133b8d48a913f8d104a51"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, @@ -36,7 +36,7 @@ "simple_sat": {:hex, :simple_sat, "0.1.3", "f650fc3c184a5fe741868b5ac56dc77fdbb428468f6dbf1978e14d0334497578", [:mix], [], "hexpm", "a54305066a356b7194dc81db2a89232bacdc0b3edaef68ed9aba28dcbc34887b"}, "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, "sourceror": {:hex, :sourceror, "1.6.0", "9907884e1449a4bd7dbaabe95088ed4d9a09c3c791fb0103964e6316bc9448a7", [:mix], [], "hexpm", "e90aef8c82dacf32c89c8ef83d1416fc343cd3e5556773eeffd2c1e3f991f699"}, - "spark": {:hex, :spark, "2.2.34", "1f0a3bd86d37f86a1d26db4a34d6b0e5fb091940aee25cd40041dab1397c8ada", [:mix], [{:igniter, ">= 0.3.36 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "93f94b8f511a72f8764465ea32ff2e5376695f70e747884de2ce64bb6ac22a59"}, + "spark": {:hex, :spark, "2.2.35", "1c0bb30f340151eca24164885935de39e6ada4010555f444c813d0488990f8f3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f242d6385c287389034a0e146d8f025b5c9ab777f1ae5cf0fdfc9209db6ae748"}, "spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"}, "splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"}, "stream_data": {:hex, :stream_data, "1.1.2", "05499eaec0443349ff877aaabc6e194e82bda6799b9ce6aaa1aadac15a9fdb4d", [:mix], [], "hexpm", "129558d2c77cbc1eb2f4747acbbea79e181a5da51108457000020a906813a1a9"}, diff --git a/test/support/domain.ex b/test/support/domain.ex index 90c0680..3e3fc4c 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -15,6 +15,7 @@ defmodule AshSqlite.Test.Domain do resource(AshSqlite.Test.Account) resource(AshSqlite.Test.Organization) resource(AshSqlite.Test.Manager) + allow_unregistered?(true) end authorization do diff --git a/test/support/resources/comment.ex b/test/support/resources/comment.ex index 7c6e2fb..183aae8 100644 --- a/test/support/resources/comment.ex +++ b/test/support/resources/comment.ex @@ -32,6 +32,7 @@ defmodule AshSqlite.Test.Comment do argument(:rating, :map) change(manage_relationship(:rating, :ratings, on_missing: :ignore, on_match: :create)) + transaction?(false) end end diff --git a/test/support/resources/manager.ex b/test/support/resources/manager.ex index 725f596..50ece4a 100644 --- a/test/support/resources/manager.ex +++ b/test/support/resources/manager.ex @@ -18,6 +18,7 @@ defmodule AshSqlite.Test.Manager do argument(:organization_id, :uuid, allow_nil?: false) change(manage_relationship(:organization_id, :organization, type: :append_and_remove)) + transaction?(false) end end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 968e121..91364ee 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -54,11 +54,14 @@ defmodule AshSqlite.Test.Post do on_match: :create ) ) + + transaction?(false) end update :increment_score do argument(:amount, :integer, default: 1) change(atomic_update(:score, expr((score || 0) + ^arg(:amount)))) + transaction?(false) end end diff --git a/test/transaction_test.exs b/test/transaction_test.exs new file mode 100644 index 0000000..ba098d4 --- /dev/null +++ b/test/transaction_test.exs @@ -0,0 +1,73 @@ +defmodule AshSqlite.TransactionTest do + @moduledoc false + use AshSqlite.RepoCase, async: false + + test "transactions cannot be used by default" do + assert_raise(Spark.Error.DslError, ~r/transaction\? false/, fn -> + defmodule ShouldNotCompileResource do + use Ash.Resource, + domain: AshSqlite.Test.Domain, + data_layer: AshSqlite.DataLayer, + validate_domain_inclusion?: false + + sqlite do + repo AshSqlite.TestRepo + table "should_not_compile_resource" + end + + attributes do + uuid_primary_key(:id) + end + + actions do + create :create do + primary?(true) + transaction?(true) + end + end + end + end) + end + + test "transactions can be enabled however" do + defmodule TransactionalResource do + use Ash.Resource, + domain: AshSqlite.Test.Domain, + data_layer: AshSqlite.DataLayer, + validate_domain_inclusion?: false + + sqlite do + repo AshSqlite.TestRepo + table "accounts" + enable_write_transactions? true + end + + attributes do + uuid_primary_key(:id) + attribute(:is_active, :boolean, public?: true) + end + + actions do + create :create do + accept([]) + primary?(true) + transaction?(true) + + change(fn changeset, _context -> + transaction? = AshSqlite.TestRepo.in_transaction?() |> dbg() + + changeset + |> Ash.Changeset.change_attribute(:is_active, transaction?) + end) + end + end + end + + record = + TransactionalResource + |> Ash.Changeset.for_create(:create, %{}) + |> Ash.create!() + + assert record.is_active + end +end