diff --git a/config/config.exs b/config/config.exs index 03f7ca7..afe494b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -6,4 +6,5 @@ config :ecto_model, EctoModel.Repo, database: System.fetch_env!("POSTGRES_DB"), username: System.fetch_env!("POSTGRES_USER"), password: System.fetch_env!("POSTGRES_PASSWORD"), + pool: Ecto.Adapters.SQL.Sandbox, hostname: "localhost" diff --git a/lib/ecto_model/repo.ex b/lib/ecto_model/repo.ex index b1a11a6..2b6dd55 100644 --- a/lib/ecto_model/repo.ex +++ b/lib/ecto_model/repo.ex @@ -4,5 +4,20 @@ if Mix.env() == :test do use Ecto.Repo, otp_app: :ecto_model, adapter: Ecto.Adapters.Postgres + + use EctoMiddleware + + @dialyzer {:nowarn_function, middleware: 2} + def middleware(_resource, _action) do + [EctoModel.SoftDelete, EctoMiddleware.Super] + end + + def soft_delete!(resource, opts \\ []) do + EctoModel.SoftDelete.soft_delete!(resource, Keyword.put(opts, :repo, __MODULE__)) + end + + def soft_delete(resource, opts \\ []) do + EctoModel.SoftDelete.soft_delete(resource, Keyword.put(opts, :repo, __MODULE__)) + end end end diff --git a/lib/ecto_model/soft_delete.ex b/lib/ecto_model/soft_delete.ex index a58bb5a..a29d6eb 100644 --- a/lib/ecto_model/soft_delete.ex +++ b/lib/ecto_model/soft_delete.ex @@ -14,6 +14,18 @@ defmodule EctoModel.SoftDelete do In future, we may add support for automatically delegating hard delete operations to transparently behave transparently as soft deletes in an opt in basis. + You can also optionally add the following code to your `MyApp.Repo` module to enable easy soft delete operations: + + ```elixir + def soft_delete!(resource, opts \\ []) do + EctoModel.SoftDelete.soft_delete!(resource, Keyword.put(opts, :repo, __MODULE__)) + end + + def soft_delete(resource, opts \\ []) do + EctoModel.SoftDelete.soft_delete(resource, Keyword.put(opts, :repo, __MODULE__)) + end + ``` + 2) You need to `use EctoModel.SoftDelete` in your schema, and configure the `field` and `type` options. The specified field and type must match what is defined on said schema, though there are compile time validations provided for you to ensure @@ -81,6 +93,8 @@ defmodule EctoModel.SoftDelete do type = __MODULE__.soft_delete_type!(opts[:type]) quote location: :keep do + @after_compile unquote(__MODULE__) + def soft_delete_config, do: %unquote(__MODULE__).Config{field: unquote(field), type: unquote(type)} @@ -120,24 +134,24 @@ defmodule EctoModel.SoftDelete do end defp field_not_configured(schema, %Config{} = config) when is_atom(schema) do - raise """ + raise ArgumentError, """ The `#{inspect(schema)}` schema is configured to implement soft deletes via the - `:#{inspect(config.field)}` field, but this field does not exist on said schema. + `#{inspect(config.field)}` field, but this field does not exist on said schema. - Please ensure that the `:#{inspect(config.field)}` field is defined on the schema, - with the type `:#{inspect(config.type)}`, or change the configuration to point + Please ensure that the `#{inspect(config.field)}` field is defined on the schema, + with the type `#{inspect(config.type)}`, or change the configuration to point to a different field via the `field: field_name :: atom()` when `use`-ing `inspect(#{__MODULE__})` """ end defp field_type_mismatch(schema, %Config{} = config) when is_atom(schema) do - raise """ + raise ArgumentError, """ The `#{inspect(schema)}` schema is configured to implement soft deletes via the - `:#{inspect(config.field)}` field of type `:#{inspect(config.type)}`, but this field - is defined on the schema with a different type. + `#{inspect(config.field)}` field of type `#{inspect(config.type)}`, + but this field has the wrong type. - Please ensure that the `:#{inspect(config.field)}` field is defined on the schema, + Please ensure that the `#{inspect(config.field)}` field is defined on the schema, with the type `:#{inspect(config.type)}`, or change the configuration to point to a different field via the `type: type_name :: atom()` when `use`-ing `inspect(#{__MODULE__})` @@ -212,11 +226,15 @@ defmodule EctoModel.SoftDelete do def apply_filter!(schema, query) when is_atom(schema) do import Ecto.Query + # This clause is only ever going to be reached if someone does something naughty! + # coveralls-ignore-start unless function_exported?(schema, :soft_delete_config, 0) do raise ArgumentError, message: "The `#{inspect(schema)}` schema is not configured to implement soft deletes." end + # coveralls-ignore-stop + case schema.soft_delete_config() do %Config{type: :boolean} = config -> from(x in query, @@ -234,11 +252,16 @@ defmodule EctoModel.SoftDelete do resource end + # TODO: this fallback clause will never be reached until `EctoMiddleware` supports `delete_all/2` + # coveralls-ignore-start def middleware(%Ecto.Query{} = queryable, resolution) do schema = case queryable.from.source do - {_table, schema} when is_atom(schema) -> schema - _otherwise -> nil + {_table, schema} when is_atom(schema) -> + schema + + _otherwise -> + nil end :ok = maybe_validate_repo_action!(schema, resolution.action) @@ -246,12 +269,16 @@ defmodule EctoModel.SoftDelete do queryable end + # coveralls-ignore-stop + def middleware(%schema{} = resource, resolution) when resolution.action in [:delete, :delete!, :delete_all] do :ok = maybe_validate_repo_action!(schema, resolution.action) resource end + # TODO: this clause will never be reached until `EctoMiddleware` supports `delete_all/2` + # coveralls-ignore-start def middleware(schema, resolution) when is_atom(schema) and resolution.action == :delete_all do :ok = maybe_validate_repo_action!(schema, resolution.action) schema @@ -261,9 +288,11 @@ defmodule EctoModel.SoftDelete do resource end + # coveralls-ignore-stop + defp maybe_validate_repo_action!(schema, action) when is_atom(schema) and action in [:delete, :delete!, :delete_all] do - if function_exported?(schema, :soft_delete?, 0) && schema.soft_delete?() do + if function_exported?(schema, :soft_delete_config, 0) && schema.soft_delete_config() do raise ArgumentError, message: """ You are trying to delete a schema that uses soft deletes. Please use `Repo.soft_delete/2` instead. @@ -272,4 +301,53 @@ defmodule EctoModel.SoftDelete do :ok end + + @doc " See `Ecto.Repo.soft_delete/2` for more information." + @spec soft_delete!(resource :: struct(), opts :: Keyword.t()) :: struct() | no_return() + def soft_delete!(resource, opts \\ []) do + {:ok, resource} = soft_delete(resource, opts) + resource + end + + @doc """ + Will soft delete a given resource, and persist the changes to the database, based on that resource's configured + soft delete field and type. + + Will raise if given an entity that does not opt into soft deletes. + + # TODO: we will need to implement something more fully fledged to support `delete_all/2` and the like + """ + @spec soft_delete!(resource :: struct(), opts :: Keyword.t()) :: + {:ok, struct()} | {:error, term()} + def soft_delete(%schema{} = resource, opts \\ []) do + # coveralls-ignore-start + unless opts[:repo] do + raise ArgumentError, + message: "You must provide a `:repo` option when delegating to, or using `soft_delete/2`" + end + + # coveralls-ignore-stop + + unless function_exported?(schema, :soft_delete_config, 0) do + raise ArgumentError, + message: + "The `#{inspect(schema)}` schema is not configured to implement soft deletes, please use `Repo.delete/2` instead." + end + + case schema.soft_delete_config() do + %Config{type: :boolean} = config -> + resource + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(config.field, true) + |> opts[:repo].update(opts) + + %Config{type: type} = config when type in [:utc_datetime, :datetime] -> + now = DateTime.truncate(DateTime.utc_now(), :second) + + resource + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(config.field, now) + |> opts[:repo].update(opts) + end + end end diff --git a/lib/ecto_model/test/dog.ex b/lib/ecto_model/test/dog.ex new file mode 100644 index 0000000..a2c38f5 --- /dev/null +++ b/lib/ecto_model/test/dog.ex @@ -0,0 +1,27 @@ +# coveralls-ignore-start +if Mix.env() == :test do + defmodule EctoModel.Dog do + use Ecto.Schema + use EctoModel.Queryable + use EctoModel.SoftDelete, field: :deleted_at, type: :utc_datetime + import Ecto.Changeset + + schema "dogs" do + field(:breed, :string) + field(:name, :string) + field(:date_of_birth, :date) + field(:notes, :string) + field(:deleted_at, :utc_datetime) + + belongs_to(:owner, EctoModel.Owner) + + timestamps() + end + + def changeset(owner, attrs) do + owner + |> cast(attrs, [:breed, :name, :date_of_birth, :notes, :owner_id, :deleted_at]) + |> validate_required([:breed, :name, :date_of_birth, :owner_id]) + end + end +end diff --git a/lib/ecto_model/test/owner.ex b/lib/ecto_model/test/owner.ex new file mode 100644 index 0000000..7ee2c41 --- /dev/null +++ b/lib/ecto_model/test/owner.ex @@ -0,0 +1,21 @@ +# coveralls-ignore-start +if Mix.env() == :test do + defmodule EctoModel.Owner do + use Ecto.Schema + use EctoModel.Queryable + import Ecto.Changeset + + schema "owners" do + field(:name, :string) + field(:email, :string) + field(:phone, :string) + has_many(:dogs, EctoModel.Dog) + end + + def changeset(owner, attrs) do + owner + |> cast(attrs, [:name, :email, :phone]) + |> validate_required([:name, :email, :phone]) + end + end +end diff --git a/lib/ecto_model/test/vaccination.ex b/lib/ecto_model/test/vaccination.ex new file mode 100644 index 0000000..228e7a6 --- /dev/null +++ b/lib/ecto_model/test/vaccination.ex @@ -0,0 +1,25 @@ +# coveralls-ignore-start +if Mix.env() == :test do + defmodule EctoModel.Vaccination do + use Ecto.Schema + use EctoModel.Queryable + use EctoModel.SoftDelete, field: :deleted, type: :boolean + + import Ecto.Changeset + + schema "vaccinations" do + field(:name, :string) + field(:date, :date) + field(:deleted, :boolean) + belongs_to(:dog, EctoModel.Dog) + + timestamps() + end + + def changeset(vaccination, attrs) do + vaccination + |> cast(attrs, [:name, :date, :dog_id, :deleted]) + |> validate_required([:name, :date, :dog_id]) + end + end +end diff --git a/mix.exs b/mix.exs index 3e0838f..6886ae5 100644 --- a/mix.exs +++ b/mix.exs @@ -31,10 +31,14 @@ defmodule EctoModel.MixProject do homepage_url: "https://github.com/vetspire/ecto_model", docs: [ main: "EctoModel" - ] + ], + elixirc_paths: elixirc_paths(Mix.env()) ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + def application do [ mod: {EctoModel.Application, []}, @@ -67,6 +71,7 @@ defmodule EctoModel.MixProject do # are nice to have for tests. {:postgrex, "~> 0.15", only: :test}, {:ecto_sql, "~> 3.6", only: :test}, + {:ex_machina, "~> 2.7.0", only: :test}, # Runtime dependencies for tests / linting {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index a5a0aef..9d88635 100644 --- a/mix.lock +++ b/mix.lock @@ -13,6 +13,7 @@ "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, + "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, "excoveralls": {:hex, :excoveralls, "0.15.0", "ac941bf85f9f201a9626cc42b2232b251ad8738da993cf406a4290cacf562ea4", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9631912006b27eca30a2f3c93562bc7ae15980afb014ceb8147dc5cdd8f376f1"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, diff --git a/priv/repo/migrations/20221221123643_bootstrap_testing_env.exs b/priv/repo/migrations/20221221123643_bootstrap_testing_env.exs index b8e6796..c297558 100644 --- a/priv/repo/migrations/20221221123643_bootstrap_testing_env.exs +++ b/priv/repo/migrations/20221221123643_bootstrap_testing_env.exs @@ -2,9 +2,28 @@ defmodule EctoModel.Repo.Migrations.BootstrapTables do use Ecto.Migration def change do - create table(:dog) do + create table(:owners) do + add(:name, :string, null: false) + add(:email, :string, null: false) + add(:phone, :string, null: false) + end + + create table(:dogs) do add(:breed, :string, null: false) add(:name, :map, null: false) + add(:date_of_birth, :date, null: false) + add(:notes, :text) + add(:owner_id, references(:owners, on_delete: :delete_all), null: false) + add(:deleted_at, :utc_datetime) + + timestamps() + end + + create table(:vaccinations) do + add(:name, :string, null: false) + add(:date, :date, null: false) + add(:dog_id, references(:dogs, on_delete: :delete_all), null: false) + add(:deleted, :boolean) timestamps() end diff --git a/test/ecto_model/queryable_test.exs b/test/ecto_model/queryable_test.exs new file mode 100644 index 0000000..98ffa3b --- /dev/null +++ b/test/ecto_model/queryable_test.exs @@ -0,0 +1,425 @@ +defmodule EctoModel.QueryableTest do + use ExUnit.Case, async: true + import EctoModel.Factory + + alias EctoModel.Dog + alias EctoModel.Owner + alias EctoModel.Queryable + alias EctoModel.Repo + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(EctoModel.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(EctoModel.Repo, {:shared, self()}) + end + + :ok + end + + describe "__using__/1" do + test "adds `base_query/0` to the schema module" do + assert %Ecto.Query{} = query = Owner.base_query() + assert query.aliases == %{self: 0} + assert query.from.source == {"owners", Owner} + end + + test "adds `query/2` to the schema module" do + insert(:dog, + name: "Buddy", + breed: "Golden Retriever", + date_of_birth: ~D[2015-01-01], + deleted_at: nil + ) + + assert [] = Repo.all(Dog.query(name: "does not exist")) + assert [%Dog{} = dog] = Repo.all(Dog.query(name: "Buddy")) + assert [^dog] = Repo.all(Dog.query(breed: "Golden Retriever")) + assert [] = Repo.all(Dog.query(date_of_birth: {:>=, Date.utc_today()})) + + refute is_nil(dog.owner_id) + assert is_struct(dog.owner, Ecto.Association.NotLoaded) + + assert [%Dog{owner: %Owner{} = owner}] = Repo.all(Dog.query(preload: :owner)) + assert [] = Repo.all(Owner.query(name: "Spongebob")) + assert [^owner] = Repo.all(Owner.query(id: owner.id)) + + assert is_struct(owner.dogs, Ecto.Association.NotLoaded) + + assert [%Owner{dogs: [%Dog{}]}] = Repo.all(Owner.query(preload: :dogs)) + end + end + + describe "implemented_by?/1" do + test "returns false when given model does not `use EctoModel.Queryable`" do + refute Queryable.implemented_by?(Enum) + end + + test "returns true when given model `use EctoModel.Queryable`" do + for schema <- [Owner, Dog], do: assert(Queryable.implemented_by?(schema)) + end + end + + describe "apply_filter/2" do + setup do + {:ok, query: Dog} + end + + test "is able to change the schema prefix", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:prefix, "test"}) + assert query.from.prefix == "test" + end + + test "is able to preload singular associations", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:preload, :owner}) + assert query.preloads == [:owner] + end + + test "is able to preload a list of associations", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:preload, [:owner]}) + assert query.preloads == [:owner] + end + + test "is able to add limit to the query", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:limit, 10}) + assert match?(%Ecto.Query.LimitExpr{params: [{10, :integer}]}, query.limit) + end + + test "is able to add offset to the query", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:offset, 10}) + assert match?(%Ecto.Query.QueryExpr{params: [{10, :integer}]}, query.offset) + end + + test "is able to order the query (literal)", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:order_by, :name}) + + assert match?( + %Ecto.Query.QueryExpr{expr: [desc: {{_, _, [_, :name]}, _, _}]}, + hd(query.order_bys) + ) + end + + test "is able to order the query (list asc)", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:order_by, [asc: :name]}) + + assert match?( + %Ecto.Query.QueryExpr{expr: [asc: {{_, _, [_, :name]}, _, _}]}, + hd(query.order_bys) + ) + end + + test "is able to order the query (list desc)", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:order_by, [desc: :name]}) + + assert match?( + %Ecto.Query.QueryExpr{expr: [desc: {{_, _, [_, :name]}, _, _}]}, + hd(query.order_bys) + ) + end + + test "is able to order the query (tuple asc)", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert %Ecto.Query{} = query = Queryable.apply_filter(ctx.query, {:order_by, {:asc, :name}}) + + assert match?( + %Ecto.Query.QueryExpr{expr: [asc: {{_, _, [_, :name]}, _, _}]}, + hd(query.order_bys) + ) + end + + test "is able to order the query (tuple desc)", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert %Ecto.Query{} = + query = Queryable.apply_filter(ctx.query, {:order_by, {:desc, :name}}) + + assert match?( + %Ecto.Query.QueryExpr{expr: [desc: {{_, _, [_, :name]}, _, _}]}, + hd(query.order_bys) + ) + end + + test "is able to filter on `inserted_at_start`", ctx do + datetime = ~U[2022-01-01 00:00:00Z] + refute is_struct(ctx.query, Ecto.Query) + + assert %Ecto.Query{} = + query = + Queryable.apply_filter(ctx.query, {:inserted_at_start, datetime}) + + assert match?( + %Ecto.Query.BooleanExpr{ + expr: {:>=, _, [{{:., _, [_, :inserted_at]}, _, _}, {:^, _, [0]}]}, + params: [{^datetime, {0, :inserted_at}}], + op: :and + }, + hd(query.wheres) + ) + end + + test "is able to filter on `inserted_at_end`", ctx do + datetime = ~U[2022-01-01 00:00:00Z] + refute is_struct(ctx.query, Ecto.Query) + + assert %Ecto.Query{} = + query = + Queryable.apply_filter(ctx.query, {:inserted_at_end, datetime}) + + assert match?( + %Ecto.Query.BooleanExpr{ + expr: {:<=, _, [{{:., _, [_, :inserted_at]}, _, _}, {:^, _, [0]}]}, + params: [{^datetime, {0, :inserted_at}}], + op: :and + }, + hd(query.wheres) + ) + end + + test "is able to filter on `updated_at_start`", ctx do + datetime = ~U[2022-01-01 00:00:00Z] + refute is_struct(ctx.query, Ecto.Query) + + assert %Ecto.Query{} = + query = + Queryable.apply_filter(ctx.query, {:updated_at_start, datetime}) + + assert match?( + %Ecto.Query.BooleanExpr{ + expr: {:>=, _, [{{:., _, [_, :updated_at]}, _, _}, {:^, _, [0]}]}, + params: [{^datetime, {0, :updated_at}}], + op: :and + }, + hd(query.wheres) + ) + end + + test "is able to filter on `updated_at_end`", ctx do + datetime = ~U[2022-01-01 00:00:00Z] + refute is_struct(ctx.query, Ecto.Query) + + assert %Ecto.Query{} = + query = + Queryable.apply_filter(ctx.query, {:updated_at_end, datetime}) + + assert match?( + %Ecto.Query.BooleanExpr{ + expr: {:<=, _, [{{:., _, [_, :updated_at]}, _, _}, {:^, _, [0]}]}, + params: [{^datetime, {0, :updated_at}}], + op: :and + }, + hd(query.wheres) + ) + end + + test "is able to filter by regex (case-sensitive)", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:name, ~r/buddy/}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"name\" ~ $1)" + assert Enum.at(params, 0) == "buddy" + end + + test "is able to filter by regex (case-insensitive)", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:name, ~r/riley/i}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"name\" ~* $1)" + assert Enum.at(params, 0) == "riley" + end + + test "is able to filter for non-inclusion of a list of values", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:name, {:not, ["Cindy", "Judy"]}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (NOT (d0.\"name\" = ANY($1)))" + assert Enum.at(params, 0) == ["Cindy", "Judy"] + end + + test "is able to filter for inclusion of a list of values", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:name, ["Buddy", "Riley"]}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"name\" = ANY($1))" + assert Enum.at(params, 0) == ["Buddy", "Riley"] + end + + test "is able to filter for non-null values", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, []} = + ctx.query + |> Queryable.apply_filter({:name, {:not, nil}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (NOT (d0.\"name\" IS NULL))" + end + + test "is able to filter for null values", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, []} = + ctx.query + |> Queryable.apply_filter({:name, nil}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"name\" IS NULL)" + end + + test "is able to filter for inequality", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:name, {:not, "Bob"}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"name\" != $1)" + assert Enum.at(params, 0) == "Bob" + end + + test "is able to filter for equality", ctx do + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:name, "Bob"}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"name\" = $1)" + assert Enum.at(params, 0) == "Bob" + end + + test "is able to filter for values greater than (`:gt`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:gt, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" > $1)" + assert Enum.at(params, 0) == now + end + + test "is able to filter for values greater than (`:>`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:>, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" > $1)" + assert Enum.at(params, 0) == now + end + + test "is able to filter for values greater than or equal to (`:gte`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:gte, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" >= $1)" + assert Enum.at(params, 0) == now + end + + test "is able to filter for values greater than or equal to (`:>=`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:>=, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" >= $1)" + assert Enum.at(params, 0) == now + end + + test "is able to filter for values less than (`:lt`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:lt, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" < $1)" + assert Enum.at(params, 0) == now + end + + test "is able to filter for values less than (`:<`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:<, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" < $1)" + assert Enum.at(params, 0) == now + end + + test "is able to filter for values less than or equal to (`:lte`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:lte, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" <= $1)" + assert Enum.at(params, 0) == now + end + + test "is able to filter for values less than or equal to (`:<=`)", ctx do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + refute is_struct(ctx.query, Ecto.Query) + + assert {query, params} = + ctx.query + |> Queryable.apply_filter({:inserted_at, {:<=, now}}) + |> then(&Repo.to_sql(:all, &1)) + + assert query =~ "WHERE (d0.\"inserted_at\" <= $1)" + assert Enum.at(params, 0) == now + end + + test "does nothing with unsupported filters", ctx do + refute is_struct(ctx.query, Ecto.Query) + assert ctx.query == Queryable.apply_filter(ctx.query, :unsupported) + end + end +end diff --git a/test/ecto_model/soft_delete_test.exs b/test/ecto_model/soft_delete_test.exs new file mode 100644 index 0000000..7b42a2c --- /dev/null +++ b/test/ecto_model/soft_delete_test.exs @@ -0,0 +1,213 @@ +defmodule EctoModel.SoftDeleteTest do + use ExUnit.Case, async: true + + import EctoModel.Factory + + alias EctoModel.Dog + alias EctoModel.Owner + alias EctoModel.Vaccination + alias EctoModel.Repo + + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(EctoModel.Repo) + + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(EctoModel.Repo, {:shared, self()}) + end + + :ok + end + + setup do + vaccination = + insert(:vaccination, + name: "Rabies", + date: ~D[2022-12-21], + dog: + insert(:dog, + breed: "Golden Retriever", + name: "Buddy", + date_of_birth: ~D[2019-12-21], + owner: insert(:owner, name: "John Doe") + ) + ) + + {:ok, vaccination: vaccination, dog: vaccination.dog, owner: vaccination.dog.owner} + end + + describe "__after_compile__/2" do + test "throws an exception if the schema has `field` misconfigured" do + assert_raise ArgumentError, ~r/this field does not exist/, fn -> + Code.eval_string(""" + defmodule Test.SchemaOne do + use Ecto.Schema + use EctoModel.Queryable + use EctoModel.SoftDelete, field: :deleted, type: :boolean + + schema "schema_ones" do + field(:deleted_at, :string) + end + end + """) + end + end + + test "throws an exception if the schema has `type` misconfigured (schema)" do + assert_raise ArgumentError, ~r/this field has the wrong type/, fn -> + Code.eval_string(""" + defmodule Test.SchemaTwo do + use Ecto.Schema + use EctoModel.Queryable + use EctoModel.SoftDelete, field: :deleted, type: :boolean + + schema "schema_twos" do + field(:deleted, :string) + end + end + """) + end + end + + test "throws an exception if the schema has `type` misconfigured (config)" do + assert_raise ArgumentError, ~r/Unsupported soft delete type/, fn -> + Code.eval_string(""" + defmodule Test.SchemaThree do + use Ecto.Schema + use EctoModel.Queryable + use EctoModel.SoftDelete, field: :deleted, type: :string + + schema "schema_twos" do + field(:deleted, :boolean) + end + end + """) + end + end + + test "assumes `field` is `deleted_at` and `type` is `utc_datetime` when not explicitly set" do + assert {{:module, Test.SchemaFour, _binary, _bindings}, []} = + Code.eval_string(""" + defmodule Test.SchemaFour do + use Ecto.Schema + use EctoModel.Queryable + use EctoModel.SoftDelete + + schema "schema_twos" do + field(:deleted_at, :utc_datetime) + end + end + """) + end + + test "does nothing if a schema is configured correctly" do + assert {{:module, Test.SchemaFive, _binary, _bindings}, []} = + Code.eval_string(""" + defmodule Test.SchemaFive do + use Ecto.Schema + use EctoModel.Queryable + use EctoModel.SoftDelete, field: :deleted, type: :boolean + + schema "schema_twos" do + field(:deleted, :boolean) + end + end + """) + end + end + + describe "__using__/1" do + test "if schema also implements `EctoModel.Queryable`, adjusts `base_query/0` to exclude `:deleted` records" do + # This schema uses `EctoModel.Queryable` and `EctoModel.SoftDelete` + assert Dog.base_query() |> then(&Repo.to_sql(:all, &1)) |> elem(0) =~ + "WHERE (d0.\"deleted_at\" IS NULL)" + + # This schema uses `EctoModel.Queryable` and `EctoModel.SoftDelete`, but is a boolean field + assert Vaccination.base_query() |> then(&Repo.to_sql(:all, &1)) |> elem(0) =~ + "WHERE ((v0.\"deleted\" IS NULL) OR (v0.\"deleted\" = FALSE)" + + # This schema only uses `EctoModel.Queryable` + refute Owner.base_query() |> then(&Repo.to_sql(:all, &1)) |> elem(0) =~ + "WHERE (d0.\"deleted_at\" IS NULL)" + end + end + + describe "middleware/2" do + import Ecto.Query + + test "raises if you try hard deleting a struct that opts into soft deletes", ctx do + for struct <- [ctx.dog, ctx.vaccination] do + assert_raise ArgumentError, + ~r/You are trying to delete a schema that uses soft deletes. Please use `Repo.soft_delete\/2` instead/, + fn -> Repo.delete(struct) end + end + + # This is allowed because this schema does not use `EctoModel.SoftDelete` + assert {:ok, _owner} = Repo.delete(ctx.owner) + end + + test "raises if you try hard deleting a query that opts whose source opts into soft deletes", + ctx do + for %schema{} <- [ctx.dog, ctx.vaccination] do + assert_raise ArgumentError, + ~r/You are trying to delete a schema that uses soft deletes. Please use `Repo.soft_delete\/2` instead/, + fn -> Repo.delete(from(x in schema)) end + end + end + end + + describe "soft_delete!/2" do + test "soft deletes a struct that opts into soft deletes (timestamp)", ctx do + assert Repo.exists?(Dog.query([])) + assert is_nil(ctx.dog.deleted_at) + + assert %Dog{} = dog = Repo.soft_delete!(ctx.dog) + + assert is_struct(dog.deleted_at) + refute Repo.exists?(Dog.query([])) + end + + test "soft deletes a struct that opts into soft deletes (boolean)", ctx do + assert Repo.exists?(Vaccination.query([])) + assert ctx.vaccination.deleted == false + + assert %Vaccination{} = vaccination = Repo.soft_delete!(ctx.vaccination) + + assert vaccination.deleted == true + refute Repo.exists?(Vaccination.query([])) + end + + test "raises if given a module that does not opt into soft deletes", ctx do + assert_raise ArgumentError, ~r/not configured to implement soft deletes/, fn -> + Repo.soft_delete!(ctx.owner) + end + end + end + + describe "soft_delete/2" do + test "soft deletes a struct that opts into soft deletes (timestamp)", ctx do + assert Repo.exists?(Dog.query([])) + assert is_nil(ctx.dog.deleted_at) + + assert {:ok, %Dog{} = dog} = Repo.soft_delete(ctx.dog) + + assert is_struct(dog.deleted_at) + refute Repo.exists?(Dog.query([])) + end + + test "soft deletes a struct that opts into soft deletes (boolean)", ctx do + assert Repo.exists?(Vaccination.query([])) + assert ctx.vaccination.deleted == false + + assert {:ok, %Vaccination{} = vaccination} = Repo.soft_delete(ctx.vaccination) + + assert vaccination.deleted == true + refute Repo.exists?(Vaccination.query([])) + end + + test "raises if given a module that does not opt into soft deletes", ctx do + assert_raise ArgumentError, ~r/not configured to implement soft deletes/, fn -> + Repo.soft_delete(ctx.owner) + end + end + end +end diff --git a/test/ecto_model_test.exs b/test/ecto_model_test.exs deleted file mode 100644 index 858629b..0000000 --- a/test/ecto_model_test.exs +++ /dev/null @@ -1,7 +0,0 @@ -defmodule EctoModelTest do - use ExUnit.Case - - test "the truth" do - assert 1 + 1 == 2 - end -end diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 0000000..9858e7b --- /dev/null +++ b/test/support/factory.ex @@ -0,0 +1,31 @@ +defmodule EctoModel.Factory do + use ExMachina.Ecto, repo: EctoModel.Repo + + def dog_factory do + %EctoModel.Dog{ + breed: "Golden Retriever", + name: "Buddy", + date_of_birth: ~D[2015-01-01], + notes: "Friendly and loyal", + owner: build(:owner), + deleted_at: nil + } + end + + def vaccination_factory do + %EctoModel.Vaccination{ + name: "Rabies", + date: ~D[2022-12-21], + dog: build(:dog), + deleted: false + } + end + + def owner_factory do + %EctoModel.Owner{ + name: "John Smith", + email: "john@smith.cc", + phone: "123-456-7890" + } + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index bc46c8e..e63bd22 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,3 @@ Code.put_compiler_option(:warnings_as_errors, true) +{:ok, _} = Application.ensure_all_started(:ex_machina) ExUnit.start()