diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d699cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} + env: + MIX_ENV: "test" + strategy: + matrix: + elixir: ["1.12.x", "1.11.x"] + otp: ["24.0.x", "23.3.x"] + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + - name: Install Dependencies + run: mix deps.get + - name: Compile app + run: mix compile --force --warnings-as-errors + - run: mix test + - run: mix format --check-formatted diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5981ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +trash-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..a9d8b87 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +erlang 23.1.1 +elixir 1.11.0-otp-23 + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1e24d30 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at +[team+trash-coc@newaperio.com]. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. The project team is obligated to maintain +confidentiality with regard to the reporter of an incident. Further details +of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +[team+trash-coc@newaperio.com]: mailto:team+trash-coc@newaperio.com + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b8eb512 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 NewAperio, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1327d20 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# ♻️ Trash + +![](https://github.com/newaperio/trash/workflows/CI/badge.svg) + +Simple soft deletes for Ecto + +## Installation + +Trash is available on [Hex](https://hex.pm/packages/trash). + +This package can be installed by adding `trash` to your list of dependencies in +`mix.exs`: + +```elixir +def deps do + [ + {:trash, "~> 0.1.0"} + ] +end +``` + +## Usage + +Check the [documentation](https://hexdocs.pm/trash) for complete details. + +Trash helps manage soft-deleting `Ecto.Schema`s by providing convenience +functions to update and query for discarded and kept records. + +### Terminology + +Trash uses a few terms throughout to indicate the state of a record. Here are +some quick definitions: + +- **Soft-deletion**: removing a record by updating an attribute instead of + issuing a SQL `DELETE` +- **Discarded**: a record that has been soft-deleted +- **Kept**: a record that has not been soft-deleted +- **Restore**: reverse a soft-deletion to keep a record + +### Getting Started + +Trash is opt-in on individual `Ecto.Schema`s. To start marking schemas as +trashable, first add the required trashable fields: + +```bash +mix ecto.gen.migration add_trashable_to_posts +``` + +```elixir +defmodule MyApp.Repo.Migrations.AddTrashableToPosts do + use Ecto.Migration + + def change do + alter(table(:posts)) do + add(:discarded_at, :utc_datetime) + end + + create(index(:posts, :discarded_at)) + end +end +``` + +Then declare the fields on your schema. You can do this manually or use the +convenience functions in `Trash.Schema`: + +```elixir +defmodule MyApp.Posts.Post do + use Ecto.Schema + use Trash.Schema + + schema "posts" do + field(:title, :string) + trashable_fields() + end +end +``` + +Next, import `Trash` by using it in your `MyApp.Repo`. + +```elixir +defmodule MyApp.Repo do + use Ecto.Repo, + otp_app: :my_app, + adapter: Ecto.Adapters.Postgres + + use Trash.Repo, repo: __MODULE__ +end +``` + +This generates shorthand functions with the repo implicitly passed. However, +it's not required to call `use`. If preferred you can call the functions +directly on `Trash.Repo` by passing the `Ecto.Repo` manually. It's a bit more +convenient with `use`, though. + +```elixir +# Shorthand with `use` +MyRepo.all_discarded(Post) + +# Long form without +MyRepo.all_discarded(Post, [], MyRepo) +``` + +### Soft-deleting and Restoring + +The functions `discard` and `restore` will soft-delete and restore records, +respectively. + +```elixir +alias MyApp.Posts +alias MyApp.Repo + +post = Posts.get_last_post! + +{:ok, post} = Repo.discard(post) # => %Post{discarded_at: %DateTime{}} +post = Repo.restore(post) # => %Post{discarded_at: nil} +``` + +These call out to the repo's `update` function. This means a SQL `UPDATE` has +been issued and the returned schema has updated trashable fields. + +These functions also have bang versions, which unwrap the return tuple and raise +on error. Note: when passing a struct instead of a changeset, the bang versions +of these will never raise an error. + +### Querying + +Trash provides `discarded` and `kept` variations of the following `Ecto.Repo` +functions: + +- `all` +- `exists?` +- `get` +- `get!` +- `get_by` +- `get_by!` +- `one` +- `one!` + +The variations are postfixed with `discarded` and `kept` (with the exception of +`exists?` which is replaced by `discarded?` and `kept?`) and modify the +passed-in queryable to add a `WHERE` condition to only return discarded or kept +records. + +Trash also provides helper `where` functions that can be used in conjunction +with `Ecto.Query`. + +```elixir +import Ecto.Query +alias MyApp.Posts.Post + +from(p in Post) |> Trash.Query.where_discarded() |> Repo.all() +``` + +There is also a function that merges in the trashable fields into the select +statement to always ensure they are returned. It also hydrates `discarded?` with +a computed `SQL` value. + +```elixir +import Ecto.Query +alias MyApp.Posts.Post +alias MyApp.Repo + +Post +|> Trash.Query.where_discarded() +|> Repo.all() +|> Trash.Query.select_trashable() +``` + +## Contributing + +Contributions are welcome! To make changes, clone the repo, make sure tests +pass, and then open a PR on GitHub. + +```console +git clone https://github.com/newaperio/trash.git +cd trash +mix deps.get +mix test +``` + +## License + +Trash is Copyright © 2020 NewAperio. It is free software, and may be +redistributed under the terms specified in the [LICENSE](/LICENSE) file. + +## About NewAperio + +Trash is built by NewAperio, LLC. + +NewAperio is a web and mobile design and development studio. We offer [expert +Elixir and Phoenix][services] development as part of our portfolio of services. +[Get in touch][contact] to see how our team can help you. + +[services]: https://newaperio.com/services#elixir?utm_source=github +[contact]: https://newaperio.com/contact?utm_source=github + diff --git a/lib/trash.ex b/lib/trash.ex new file mode 100644 index 0000000..8960a7d --- /dev/null +++ b/lib/trash.ex @@ -0,0 +1,17 @@ +defmodule Trash do + @moduledoc """ + Trash helps manage soft-deleting `Ecto.Schema`s by providing convenience + functions to update and query for discarded and kept records. + + ## Terminology + + Trash uses a few terms throughout to indicate the state of a record. Here's + some quick definitions: + + - **Soft-deletion**: removing a record by updating an attribute instead of + issuing a SQL `DELETE` + - **Discarded**: a record that has been soft-deleted + - **Kept**: a record that has not been soft-deleted + - **Restore**: reverse a soft-deletion to keep a record + """ +end diff --git a/lib/trash/query.ex b/lib/trash/query.ex new file mode 100644 index 0000000..d6c5439 --- /dev/null +++ b/lib/trash/query.ex @@ -0,0 +1,84 @@ +defmodule Trash.Query do + @moduledoc """ + Provides query methods for working with records that implement `Trash`. + + Schemas should first include `Trash.Schema` and/or manually add the necessary + fields for these to work. + """ + require Ecto.Query + + alias Ecto.Query + alias Ecto.Queryable + + @doc """ + Adds trashable fields to select. + + This ensures that both trashable fields are included in the select statement + by using `Ecto.Query.select_merge/3` to merge in the fields. + + For a list of the current trashable fields, see + `Trash.Schema.trashable_fields/0`. + + This loads `discarded_at` from the database and computes the boolean for + `discarded?` from the SQL expression `discarded_at IS NOT NULL`. + + Note: Since `discarded?` is a virtual field, without using this function, + it'll be `nil` by default. + + ## Examples + + iex> Trash.Query.select_trashable(Post) |> Repo.all() + [%Post{title: "Hello World", discarded_at: %DateTime{}, discarded?: true}] + + iex> Trash.Query.select_trashable(Post) |> Repo.all() + [%Post{title: "Hello World", discarded_at: nil, discarded?: false}] + + """ + @spec select_trashable(queryable :: Ecto.Queryable.t()) :: Ecto.Queryable.t() + def select_trashable(queryable) do + queryable + |> Queryable.to_query() + |> Query.select_merge([t], %{ + discarded_at: t.discarded_at, + discarded?: not is_nil(t.discarded_at) + }) + end + + @doc """ + Adds a where clause for returning discarded records. + + This adds a where clause equivalent to the SQL expression `discarded_at IS NOT + NULL` which denotes a record that has been discarded. + + ## Examples + + iex> Trash.Query.where_discarded(Post) |> Repo.all() + [%Post{title: "Hello World", discarded_at: %DateTime{}, discarded?: nil}] + + """ + @spec where_discarded(queryable :: Ecto.Queryable.t()) :: Ecto.Queryable.t() + def where_discarded(queryable) do + queryable + |> Queryable.to_query() + |> Query.where([t], not is_nil(t.discarded_at)) + end + + @doc """ + Adds a where clause for returning kept records. + + This adds a where clause equivalent to the SQL expression `discarded_at IS + NULL` which denotes a record that has been kept. + + ## Examples + + iex> Trash.Query.where_kept(Post) |> Repo.all() + [%Post{title: "Hello World", discarded_at: nil, discarded?: nil}] + + """ + @spec where_kept(queryable :: Ecto.Queryable.t()) :: Ecto.Queryable.t() + def where_kept(queryable) do + queryable + |> Queryable.to_query() + |> Query.where([t], is_nil(t.discarded_at)) + end +end diff --git a/lib/trash/repo.ex b/lib/trash/repo.ex new file mode 100644 index 0000000..f762af8 --- /dev/null +++ b/lib/trash/repo.ex @@ -0,0 +1,1107 @@ +defmodule Trash.Repo do + @moduledoc """ + Provides functions for discarding and keeping records and querying for them + via `Ecto.Repo` functions. + """ + require Ecto.Query + + alias Ecto.Query + alias Ecto.Queryable + alias Ecto.Changeset + alias Trash.Query, as: TrashQuery + + @doc """ + Imports functions from `Trash.Repo`. + + It's not required to `use` this module in order to use `Trash`. Doing so + will import shorthand functions into your app's `Repo` module with the repo + implicitly passed. It's a bit more convenient, but the functions are public + on `Trash.Repo`, so if preferred they can be called directly. + + ``` + # Shorthand with `use` + MyRepo.all_discarded(Post) + + # Long form without + Trash.Repo.all_discarded(Post, [], MyRepo) + ``` + + ## Options + + - `repo` - A module reference to an `Ecto.Repo`; raises `ArgumentError` if + missing + + ## Examples + + defmodule MyApp.Repo + use Ecto.Schema + use Trash.Schema, repo: __MODULE__ + end + + """ + + # credo:disable-for-this-file Credo.Check.Refactor.LongQuoteBlocks + # credo:disable-for-this-file Credo.Check.Consistency.ParameterPatternMatching + + @spec __using__(opts :: list()) :: Macro.t() + defmacro __using__(opts) do + quote do + import unquote(__MODULE__) + + @__trash_repo__ unquote(compile_config(opts)) + + @doc """ + Fetches all entries matching the given query that have been discarded. + + ## Examples + + iex> MyRepo.all_discarded(Post) + [%Post{ + title: "Hello World", + discarded_at: %DateTime{}, + discarded?: nil + }] + + """ + @spec all_discarded( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: [Ecto.Schema.t()] + def all_discarded(queryable, opts \\ []) do + all_discarded(queryable, opts, @__trash_repo__) + end + + @doc """ + Fetches all entries matching the given query that have been kept. + + ## Examples + + iex> MyRepo.all_kept(Post) + [%Post{title: "Hello World", discarded_at: nil, discarded?: nil}] + + """ + @spec all_kept( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: [Ecto.Schema.t()] + def all_kept(queryable, opts \\ []) do + all_kept(queryable, opts, @__trash_repo__) + end + + @doc """ + Updates a record as discarded. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a + struct is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to + `DateTime.utc_now/1`. It calls `repo.update/2` to finalize the changes. + + It returns `{:ok, struct}` if the struct has been successfully updated + or `{:error, changeset}` if there was an error. + + ## Examples + + iex> Post.changeset(post, %{title: "[Archived] Hello, world"}) + |> MyRepo.discard() + {:ok, + %Post{title: "[Archived] Hello, world", discarded_at: %DateTime{}}} + + """ + @spec discard(changeset_or_schema :: Changeset.t() | Ecto.Schema.t()) :: + {:ok, Ecto.Schema.t()} | {:error, Changeset.t()} + def discard(changeset = %Changeset{}) do + discard(changeset, @__trash_repo__) + end + + def discard(%{__struct__: _} = struct) do + discard(struct, @__trash_repo__) + end + + @doc """ + Updates a record as discarded. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a struct + is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to + `DateTime.utc_now/1`. It calls `repo.update/2` to finalize the changes. + + Raises `Ecto.InvalidChangesetError` if the changeset is invalid. + + Note: since an `Ecto.Schema` struct can be passed which generates a bare + changeset, this will never raise when given a struct. + + ## Examples + + iex> Post.changeset(post, %{title: "[Archived] Hello, world"}) + |> MyRepo.discard! + %Post{title: "[Archived] Hello, world", discarded_at: %DateTime{}} + + iex> Post.changeset(post, %{}) |> MyRepo.discard! + ** (Ecto.InvalidChangesetError) + + """ + @spec discard!(changeset_or_schema :: Changeset.t() | Ecto.Schema.t()) :: + Ecto.Schema.t() + def discard!(changeset = %Changeset{}) do + discard!(changeset, @__trash_repo__) + end + + def discard!(%{__struct__: _} = struct) do + discard!(struct, @__trash_repo__) + end + + @doc """ + Checks if there exists an entry that matches the given query that has been + discarded. + + Returns a boolean. + + ## Examples + + iex> MyRepo.discarded?(post) + true + + """ + @spec discarded?( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: boolean + def discarded?(queryable, opts \\ []) do + discarded?(queryable, opts, @__trash_repo__) + end + + @doc """ + Fetches a single discarded result where the primary key matches the given + `id`. + + Returns `nil` if no result was found. + + ## Examples + + iex> MyRepo.get_discarded(Post) + %Post{ + title: "Hello World", + discarded_at: %DateTime{}, + discarded?: nil + } + + """ + @spec get_discarded( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def get_discarded(queryable, id, opts \\ []) do + get_discarded(queryable, id, opts, @__trash_repo__) + end + + @doc """ + Fetches a single discarded result where the primary key matches the given + `id`. + + Raises `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> MyRepo.get_discarded!(Post, 1) + %Post{ + title: "Hello World", + discarded_at: %DateTime{}, + discarded?: nil + } + + iex> MyRepo.get_discarded!(Post, 2) + ** (Ecto.NoResultsError) + + """ + @spec get_discarded!( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def get_discarded!(queryable, id, opts \\ []) do + get_discarded!(queryable, id, opts, @__trash_repo__) + end + + @doc """ + Fetches a single discarded result from the query. + + Returns `nil` if no result was found or raises + `Ecto.MultipleResultsError` if more than one entry. + + ## Examples + + iex> MyRepo.get_discarded_by(Post, [title: "Hello World"]) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + """ + @spec get_discarded_by( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def get_discarded_by(queryable, clauses, opts \\ []) do + get_discarded_by(queryable, clauses, opts, @__trash_repo__) + end + + @doc """ + Fetches a single discarded result from the query. + + Raises `Ecto.MultipleResultsError` if more than one result. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> MyRepo.get_discarded_by!(Post, [title: "Hello World"]) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + iex> MyRepo.get_discarded_by!(Post, [title: "Unwritten"]) + ** (Ecto.NoResultsError) + + """ + @spec get_discarded_by!( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t() + ) :: Ecto.Schema.t() + def get_discarded_by!(queryable, clauses, opts \\ []) do + get_discarded_by!(queryable, clauses, opts, @__trash_repo__) + end + + @doc """ + Fetches a single kept result where the primary key matches the given `id`. + + Returns `nil` if no result was found. + + ## Examples + + iex> MyRepo.get_kept(Post, 1) + %Post{title: "Hello World", discarded_at: nil} + + """ + @spec get_kept( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def get_kept(queryable, id, opts \\ []) do + get_kept(queryable, id, opts, @__trash_repo__) + end + + @doc """ + Fetches a single kept result where the primary key matches the given `id`. + + Raises `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> MyRepo.get_kept!(Post, 1) + %Post{title: "Hello World", discarded_at: nil} + + iex> MyRepo.get_kept!(Post, 2) + ** (Ecto.NoResultsError) + + """ + @spec get_kept!( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def get_kept!(queryable, id, opts \\ []) do + get_kept!(queryable, id, opts, @__trash_repo__) + end + + @doc """ + Fetches a single kept result from the query. + + Returns `nil` if no result was found or raises + `Ecto.MultipleResultsError` if more than one entry. + + ## Examples + + iex> MyRepo.get_kept_by(Post, title: "Hello World") + %Post{title: "Hello World", discarded_at: nil} + + """ + @spec get_kept_by( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def get_kept_by(queryable, clauses, opts \\ []) do + get_kept_by(queryable, clauses, opts, @__trash_repo__) + end + + @doc """ + Fetches a single kept result from the query. + + Raises `Ecto.MultipleResultsError` if more than one result. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> MyRepo.get_kept_by!(Post, title: "Hello World") + %Post{title: "Hello World", discarded_at: %DateTime{}} + + iex> MyRepo.get_kept_by!(Post, title: "Not Written") + ** (Ecto.NoResultsError) + + """ + @spec get_kept_by!( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t() + ) :: Ecto.Schema.t() + def get_kept_by!(queryable, clauses, opts \\ []) do + get_kept_by!(queryable, clauses, opts, @__trash_repo__) + end + + @doc """ + Checks if there exists an entry that matches the given query that has been + kept. + + Returns a boolean. + + ## Examples + + iex> MyRepo.kept?(post) + true + + """ + @spec kept?( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: boolean + def kept?(queryable, opts \\ []) do + kept?(queryable, opts, @__trash_repo__) + end + + @doc """ + Fetches a single discarded result from the query. + + Returns `nil` if no result was found or raises + `Ecto.MultipleResultsError` if more than one entry. + + ## Examples + + iex> MyRepo.one_discarded(Post) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + """ + @spec one_discarded( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def one_discarded(queryable, opts \\ []) do + one_discarded(queryable, opts, @__trash_repo__) + end + + @doc """ + Fetches a single discarded result from the query. + + Raises `Ecto.MultipleResultsError` if more than one result. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> MyRepo.one_discarded!(Post) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + iex> MyRepo.one_discarded!(Post) + ** (Ecto.NoResultsError) + + """ + @spec one_discarded!( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() + def one_discarded!(queryable, opts \\ []) do + one_discarded!(queryable, opts, @__trash_repo__) + end + + @doc """ + Fetches a single kept result from the query. + + Returns `nil` if no result was found or raises + `Ecto.MultipleResultsError` if more than one entry. + + ## Examples + + iex> MyRepo.one_kept(Post) + %Post{title: "Hello World", discarded_at: nil} + + """ + @spec one_kept( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() | nil + def one_kept(queryable, opts \\ []) do + one_kept(queryable, opts, @__trash_repo__) + end + + @doc """ + Fetches a single kept result from the query. + + Raises `Ecto.MultipleResultsError` if more than one result. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> MyRepo.one_kept!(Post) + %Post{title: "Hello World", discarded_at: nil} + + iex> MyRepo.one_kept!(Post) + ** (Ecto.NoResultsError) + + """ + @spec one_kept!( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t() + ) :: Ecto.Schema.t() + def one_kept!(queryable, opts \\ []) do + one_kept!(queryable, opts, @__trash_repo__) + end + + @doc """ + Updates a record as kept. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a struct + is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to `nil`. It calls + `repo.update/2` to finalize the changes. + + It returns `{:ok, struct}` if the struct has been successfully updated or + `{:error, changeset}` if there was an error. + + ## Examples + + iex> Post.changeset(post, %{title: "Hello, world"}) + |> MyRepo.restore() + {:ok, %Post{title: "Hello, world", discarded_at: nil}} + + """ + @spec restore(changeset_or_schema :: Changeset.t() | Ecto.Schema.t()) :: + {:ok, Ecto.Schema.t()} | {:error, Changeset.t()} + def restore(changeset = %Changeset{}) do + restore(changeset, @__trash_repo__) + end + + def restore(%{__struct__: _} = struct) do + restore(struct, @__trash_repo__) + end + + @doc """ + Updates a record as kept. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a struct + is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to `nil`. It calls + `repo.update/2` to finalize the changes. + + Raises `Ecto.InvalidChangesetError` if the changeset is invalid. + + Note: since an `Ecto.Schema` struct can be passed which generates a bare + changeset, this will never raise when given a struct. + + ## Examples + + iex> Post.changeset(post, %{title: "[Archived] Hello, world"}) + |> MyRepo.restore!() + %Post{title: "[Archived] Hello, world", discarded_at: nil} + + iex> Post.changeset(post, %{}) |> MyRepo.restore!() + ** (Ecto.InvalidChangesetError) + + """ + @spec restore!(changeset_or_schema :: Changeset.t() | Ecto.Schema.t()) :: + Ecto.Schema.t() + def restore!(changeset = %Changeset{}) do + restore!(changeset, @__trash_repo__) + end + + def restore!(%{__struct__: _} = struct) do + restore!(struct, @__trash_repo__) + end + end + end + + @doc """ + Fetches all entries matching the given query that have been discarded. + + ## Examples + + iex> Trash.Repo.all_discarded(Post, [], MyApp.Repo) + [%Post{title: "Hello World", discarded_at: %DateTime{}, discarded?: nil}] + + """ + @spec all_discarded( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: [Ecto.Schema.t()] + def all_discarded(queryable, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.all(opts) + end + + @doc """ + Fetches all entries matching the given query that have been kept. + + ## Examples + + iex> Trash.Repo.all_kept(Post, [], MyApp.Repo) + [%Post{title: "Hello World", discarded_at: nil, discarded?: nil}] + + """ + @spec all_kept( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: [Ecto.Schema.t()] + def all_kept(queryable, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.all(opts) + end + + @doc """ + Updates a record as discarded. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a struct + is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to + `DateTime.utc_now/1`. It calls `repo.update/2` to finalize the changes. + + It returns `{:ok, struct}` if the struct has been successfully updated or + `{:error, changeset}` if there was an error. + + ## Examples + + iex> Post.changeset(post, %{title: "[Archived] Hello, world"}) + |> Trash.Repo.discard(MyApp.Repo) + {:ok, %Post{title: "[Archived] Hello, world", discarded_at: %DateTime{}}} + + """ + @spec discard( + changeset_or_schema :: Changeset.t() | Ecto.Schema.t(), + repo :: atom + ) :: {:ok, Ecto.Schema.t()} | {:error, Changeset.t()} + def discard(changeset = %Changeset{}, repo) do + changeset + |> Changeset.put_change( + :discarded_at, + DateTime.truncate(DateTime.utc_now(), :second) + ) + |> repo.update() + end + + def discard(%{__struct__: _} = struct, repo) do + struct + |> Changeset.change() + |> discard(repo) + end + + @doc """ + Updates a record as discarded. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a struct + is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to + `DateTime.utc_now/1`. It calls `repo.update/2` to finalize the changes. + + Raises `Ecto.InvalidChangesetError` if the changeset is invalid. + + Note: since an `Ecto.Schema` struct can be passed which generates a bare + changeset, this will never raise when given a struct. + + ## Examples + + iex> Post.changeset(post, %{title: "[Archived] Hello, world"}) + |> Trash.Repo.discard!(MyApp.Repo) + %Post{title: "[Archived] Hello, world", discarded_at: %DateTime{}} + + iex> Post.changeset(post, %{}) |> Trash.Repo.discard!(MyApp.Repo) + ** (Ecto.InvalidChangesetError) + + """ + @spec discard!( + changeset_or_schema :: Changeset.t() | Ecto.Schema.t(), + repo :: atom + ) :: Ecto.Schema.t() + def discard!(changeset = %Changeset{}, repo) do + case discard(changeset, repo) do + {:ok, struct} -> + struct + + {:error, changeset} -> + raise Ecto.InvalidChangesetError, + action: :discard, + changeset: changeset + end + end + + def discard!(%{__struct__: _} = struct, repo) do + {:ok, struct} = discard(struct, repo) + struct + end + + @doc """ + Checks if there exists an entry that matches the given query that has been + discarded. + + Returns a boolean. + + ## Examples + + iex> Trash.Repo.discarded?(post, [], MyApp.Repo) + true + + """ + @spec discarded?( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: boolean + def discarded?(queryable, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.exists?(opts) + end + + @doc """ + Fetches a single discarded result where the primary key matches the given + `id`. + + Returns `nil` if no result was found. + + ## Examples + + iex> Trash.Repo.get_discarded(Post, 1, [], MyApp.Repo) + %Post{} + + """ + @spec get_discarded( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def get_discarded(queryable, id, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.get(id, opts) + end + + @doc """ + Fetches a single discarded result where the primary key matches the given + `id`. + + Raises `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> Trash.Repo.get_discarded!(Post, 1, [], MyApp.Repo) + %Post{} + + iex> Trash.Repo.get_discarded!(Post, 2, [], MyApp.Repo) + ** (Ecto.NoResultsError) + + """ + @spec get_discarded!( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def get_discarded!(queryable, id, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.get!(id, opts) + end + + @doc """ + Fetches a single discarded result from the query. + + Returns `nil` if no result was found or raises `Ecto.MultipleResultsError` if + more than one entry. + + ## Examples + + iex> Trash.Repo.get_discarded_by(Post, [title: "Hello World"], [], + MyApp.Repo) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + """ + @spec get_discarded_by( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def get_discarded_by(queryable, clauses, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.get_by(clauses, opts) + end + + @doc """ + Fetches a single discarded result from the query. + + Raises `Ecto.MultipleResultsError` if more than one result. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> Trash.Repo.get_discarded_by!(Post, [title: "Hello World"], [], + MyApp.Repo) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + iex> Trash.Repo.get_discarded_by!(Post, [title: "Hello World"], [], + MyApp.Repo) + ** (Ecto.NoResultsError) + + """ + @spec get_discarded_by!( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() + def get_discarded_by!(queryable, clauses, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.get_by!(clauses, opts) + end + + @doc """ + Fetches a single kept result where the primary key matches the given `id`. + + Returns `nil` if no result was found. + + ## Examples + + iex> Trash.Repo.get_kept(Post, 1, [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: nil} + + """ + @spec get_kept( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def get_kept(queryable, id, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.get(id, opts) + end + + @doc """ + Fetches a single kept result where the primary key matches the given `id`. + + Raises `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> Trash.Repo.get_kept!(Post, 1, [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: nil} + + iex> Trash.Repo.get_kept!(Post, 2, [], MyApp.Repo) + ** (Ecto.NoResultsError) + + """ + @spec get_kept!( + queryable :: Ecto.Queryable.t(), + id :: term(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def get_kept!(queryable, id, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.get!(id, opts) + end + + @doc """ + Fetches a single kept result from the query. + + Returns `nil` if no result was found or raises `Ecto.MultipleResultsError` if + more than one entry. + + ## Examples + + iex> Trash.Repo.get_kept_by(Post, title: "Hello World", [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: nil} + + """ + @spec get_kept_by( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def get_kept_by(queryable, clauses, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.get_by(clauses, opts) + end + + @doc """ + Fetches a single kept result from the query. + + Raises `Ecto.MultipleResultsError` if more than one result. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> Trash.Repo.get_kept_by!(Post, title: "Hello World", [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + iex> Trash.Repo.get_kept_by!(Post, title: "Not Written", [], MyApp.Repo) + ** (Ecto.NoResultsError) + + """ + @spec get_kept_by!( + queryable :: Ecto.Queryable.t(), + clauses :: Keyword.t() | map, + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() + def get_kept_by!(queryable, clauses, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.get_by!(clauses, opts) + end + + @doc """ + Checks if there exists an entry that matches the given query that has been + kept. + + Returns a boolean. + + ## Examples + + iex> Trash.Repo.kept?(post, [], MyApp.Repo) + true + + """ + @spec kept?( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: boolean + def kept?(queryable, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.exists?(opts) + end + + @doc """ + Fetches a single discarded result from the query. + + Returns `nil` if no result was found or raises `Ecto.MultipleResultsError` if + more than one entry. + + ## Examples + + iex> Trash.Repo.one_discarded(Post, [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + """ + @spec one_discarded( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def one_discarded(queryable, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.one(opts) + end + + @doc """ + Fetches a single discarded result from the query. + + Raises `Ecto.MultipleResultsError` if more than one entry. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> Trash.Repo.one_discarded!(Post, [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: %DateTime{}} + + iex> Trash.Repo.one_discarded!(Post, [], MyApp.Repo) + ** (Ecto.NoResultsError) + + """ + @spec one_discarded!( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() + def one_discarded!(queryable, opts \\ [], repo) do + queryable + |> discarded_queryable() + |> repo.one!(opts) + end + + @doc """ + Fetches a single kept result from the query. + + Returns `nil` if no result was found or raises `Ecto.MultipleResultsError` if + more than one entry. + + ## Examples + + iex> Trash.Repo.one_kept(Post, [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: nil} + + """ + @spec one_kept( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() | nil + def one_kept(queryable, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.one(opts) + end + + @doc """ + Fetches a single kept result from the query. + + Raises `Ecto.MultipleResultsError` if more than one entry. Raises + `Ecto.NoResultsError` if no result was found. + + ## Examples + + iex> Trash.Repo.one_kept!(Post, [], MyApp.Repo) + %Post{title: "Hello World", discarded_at: nil} + + iex> Trash.Repo.one_kept!(Post, [], MyApp.Repo) + ** (Ecto.NoResultsError) + + """ + @spec one_kept!( + queryable :: Ecto.Queryable.t(), + opts :: Keyword.t(), + repo :: atom + ) :: Ecto.Schema.t() + def one_kept!(queryable, opts \\ [], repo) do + queryable + |> kept_queryable() + |> repo.one!(opts) + end + + @doc """ + Updates a record as kept. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a struct + is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to `nil`. It calls + `repo.update/2` to finalize the changes. + + It returns `{:ok, struct}` if the struct has been successfully updated or + `{:error, changeset}` if there was an error. + + ## Examples + + iex> Post.changeset(post, %{title: "Hello, world"}) + |> Trash.Repo.restore(MyApp.Repo) + {:ok, %Post{title: "Hello, world", discarded_at: nil}} + + """ + @spec restore( + changeset_or_schema :: Changeset.t() | Ecto.Schema.t(), + repo :: atom + ) :: {:ok, Ecto.Schema.t()} | {:error, Changeset.t()} + def restore(changeset = %Changeset{}, repo) do + changeset + |> Changeset.put_change(:discarded_at, nil) + |> repo.update() + end + + def restore(%{__struct__: _} = struct, repo) do + struct + |> Changeset.change() + |> restore(repo) + end + + @doc """ + Updates a record as kept. + + This takes either an `Ecto.Changeset` or an `Ecto.Schema` struct. If a struct + is given a bare changeset is generated first. + + A change is added to the changeset to set `discarded_at` to `nil`. It calls + `repo.update/2` to finalize the changes. + + Raises `Ecto.InvalidChangesetError` if the changeset is invalid. + + Note: since an `Ecto.Schema` struct can be passed which generates a bare + changeset, this will never raise when given a struct. + + ## Examples + + iex> Post.changeset(post, %{title: "[Archived] Hello, world"}) + |> Trash.Repo.restore!(MyApp.Repo) + %Post{title: "[Archived] Hello, world", discarded_at: nil} + + iex> Post.changeset(post, %{}) |> Trash.Repo.restore!(MyApp.Repo) + ** (Ecto.InvalidChangesetError) + + """ + @spec restore!( + changeset_or_schema :: Changeset.t() | Ecto.Schema.t(), + repo :: atom + ) :: Ecto.Schema.t() + def restore!(changeset = %Changeset{}, repo) do + case restore(changeset, repo) do + {:ok, struct} -> + struct + + {:error, changeset} -> + raise Ecto.InvalidChangesetError, + action: :restore, + changeset: changeset + end + end + + def restore!(%{__struct__: _} = struct, repo) do + {:ok, struct} = restore(struct, repo) + struct + end + + defp compile_config(opts) do + case Keyword.fetch(opts, :repo) do + {:ok, value} -> + value + + :error -> + raise ArgumentError, "missing :repo option on use Trash.Repo" + end + end + + defp discarded_queryable(queryable) do + queryable + |> Queryable.to_query() + |> Query.from() + |> TrashQuery.where_discarded() + end + + defp kept_queryable(queryable) do + queryable + |> Queryable.to_query() + |> Query.from() + |> TrashQuery.where_kept() + end +end diff --git a/lib/trash/schema.ex b/lib/trash/schema.ex new file mode 100644 index 0000000..ccbdc48 --- /dev/null +++ b/lib/trash/schema.ex @@ -0,0 +1,54 @@ +defmodule Trash.Schema do + @moduledoc """ + Provides functions for integrating `Trash` with `Ecto.Schema`. + + ## Example + + defmodule MyApp.Post do + use Ecto.Schema + use Trash.Schema + + schema "posts" do + field(:title, :string) + trashable_fields() + end + end + + """ + + @doc """ + Imports functions from `Trash.Schema`. + + Currently no options are available. + """ + @spec __using__(options :: list()) :: Macro.t() + defmacro __using__([]) do + quote do + import unquote(__MODULE__) + end + end + + @doc """ + Declares fields on `Ecto.Schema` necessary for `Trash`. + + This is a macro that can be used inside of an `Ecto.Schema.schema/2` block to + add the necessary fields. + + ## Fields + + - `discarded_at` - `:utc_datetime` + - `discarded?` - `:boolean` (virtual) + + Note: under normal circumstances, `discarded?` will be `nil` since it's not + possible to load a virtual field in Ecto. Instead, use + `Trash.Query.select_trashable/1` to hydrate this field with a computed value + from the database. + """ + @spec trashable_fields() :: Macro.t() + defmacro trashable_fields do + quote do + Ecto.Schema.field(:discarded_at, :utc_datetime) + Ecto.Schema.field(:discarded?, :boolean, virtual: true) + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..f1808b9 --- /dev/null +++ b/mix.exs @@ -0,0 +1,49 @@ +defmodule Trash.MixProject do + use Mix.Project + + @repo_url "https://github.com/newaperio/trash" + + def project do + [ + app: :trash, + deps: deps(), + docs: docs(), + elixir: "~> 1.11", + elixirc_paths: ~w(lib test/support), + package: package(), + start_permanent: Mix.env() == :prod, + version: "0.1.0" + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:ecto_sql, "~> 3.0"}, + {:ex_doc, "~> 0.24", only: :dev, runtime: false} + ] + end + + defp docs do + [main: "readme", extras: ["README.md", "LICENSE"]] + end + + defp package() do + [ + description: "Simple soft deletes for Ecto", + maintainers: ["Logan Leger"], + licenses: ["MIT"], + links: %{ + "GitHub" => @repo_url, + "Made by NewAperio" => "https://newaperio.com" + } + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..c031faf --- /dev/null +++ b/mix.lock @@ -0,0 +1,14 @@ +%{ + "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, + "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, + "ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"}, + "ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"}, + "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [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", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, + "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, + "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, +} diff --git a/test/support/invalid_post.ex b/test/support/invalid_post.ex new file mode 100644 index 0000000..15d6e8f --- /dev/null +++ b/test/support/invalid_post.ex @@ -0,0 +1,19 @@ +defmodule Trash.Test.InvalidPost do + @moduledoc false + use Ecto.Schema + use Trash.Schema + + import Ecto.Changeset + + schema "posts" do + field(:title, :string) + field(:author, :string) + trashable_fields() + end + + def changeset(post, attrs) do + post + |> cast(attrs, [:title, :author]) + |> validate_required(:author) + end +end diff --git a/test/support/post.ex b/test/support/post.ex new file mode 100644 index 0000000..8c0c8e0 --- /dev/null +++ b/test/support/post.ex @@ -0,0 +1,10 @@ +defmodule Trash.Test.Post do + @moduledoc false + use Ecto.Schema + use Trash.Schema + + schema "posts" do + field(:title, :string) + trashable_fields() + end +end diff --git a/test/support/test_repo.ex b/test/support/test_repo.ex new file mode 100644 index 0000000..2b247df --- /dev/null +++ b/test/support/test_repo.ex @@ -0,0 +1,49 @@ +defmodule Trash.Test.Repo do + @moduledoc false + use Trash.Repo, repo: __MODULE__ + + @spec all(any, any) :: {:ok, :all} + def all(_queryable, _opts) do + {:ok, :all} + end + + @spec exists?(any, any) :: {:ok, :exists?} + def exists?(_queryable, _opts) do + {:ok, :exists?} + end + + @spec get(any, any, any) :: {:ok, :get, any} + def get(_queryable, id, _opts) do + {:ok, :get, id} + end + + @spec get!(any, any, any) :: {:ok, :get!, any} + def get!(_queryable, id, _opts) do + {:ok, :get!, id} + end + + @spec get_by(any, any, any) :: {:ok, :get_by, any} + def get_by(_queryable, clauses, _opts) do + {:ok, :get_by, clauses} + end + + @spec get_by!(any, any, any) :: {:ok, :get_by!, any} + def get_by!(_queryable, clauses, _opts) do + {:ok, :get_by!, clauses} + end + + @spec one(any, any) :: {:ok, :one} + def one(_queryable, _opts) do + {:ok, :one} + end + + @spec one!(any, any) :: {:ok, :one!} + def one!(_queryable, _opts) do + {:ok, :one!} + end + + @spec update(Ecto.Changeset.t()) :: {:error, Ecto.Changeset.t()} | {:ok, map} + def update(changeset) do + Ecto.Changeset.apply_action(changeset, :update) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/trash/query_test.exs b/test/trash/query_test.exs new file mode 100644 index 0000000..877c67c --- /dev/null +++ b/test/trash/query_test.exs @@ -0,0 +1,50 @@ +defmodule Trash.QueryTest do + use ExUnit.Case + + alias Trash.Test.Post + alias Trash.Query + + test "select_trashable/1 selects trashable fields" do + query = Query.select_trashable(Post) + + assert query.select.expr == select_expr() + end + + test "where_discarded/1 adds condition for discarded records" do + query = Query.where_discarded(Post) + + assert length(query.wheres) == 1 + assert List.first(query.wheres).expr == discarded_expr() + end + + test "where_kept/1 adds condition for kept records" do + query = Query.where_kept(Post) + + assert length(query.wheres) == 1 + assert List.first(query.wheres).expr == kept_expr() + end + + defp select_expr do + {:merge, [], + [ + {:&, [], [0]}, + {:%{}, [], + [ + discarded_at: {{:., [], [{:&, [], [0]}, :discarded_at]}, [], []}, + discarded?: + {:not, [], + [ + {:is_nil, [], [{{:., [], [{:&, [], [0]}, :discarded_at]}, [], []}]} + ]} + ]} + ]} + end + + defp discarded_expr do + {:not, [], [{:is_nil, [], [{{:., [], [{:&, [], [0]}, :discarded_at]}, [], []}]}]} + end + + defp kept_expr do + {:is_nil, [], [{{:., [], [{:&, [], [0]}, :discarded_at]}, [], []}]} + end +end diff --git a/test/trash/repo_test.exs b/test/trash/repo_test.exs new file mode 100644 index 0000000..5bc352f --- /dev/null +++ b/test/trash/repo_test.exs @@ -0,0 +1,125 @@ +defmodule Trash.RepoTest do + use ExUnit.Case + + alias Trash.Test.InvalidPost + alias Trash.Test.Post + alias Trash.Test.Repo + + test "all_discarded/1 calls Repo.all" do + assert {:ok, :all} = Repo.all_discarded(Post) + end + + test "all_kept/1 calls Repo.all" do + assert {:ok, :all} = Repo.all_kept(Post) + end + + test "discard/1 updates struct" do + post = %Post{title: "Hello, World"} + + assert {:ok, updated} = Repo.discard(post) + assert updated.discarded_at + end + + test "discard/1 with changeset updates struct" do + post_changeset = + %Post{title: "Hello, World"} + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(:title, "Hello, Again") + + assert {:ok, updated} = Repo.discard(post_changeset) + assert updated.discarded_at + end + + test "discard!/1 with changeset raises error on failed update" do + post_changeset = + InvalidPost.changeset(%InvalidPost{title: "Hello, World"}, %{title: "Hello, Again"}) + + assert_raise Ecto.InvalidChangesetError, fn -> + Repo.discard!(post_changeset) + end + end + + test "discarded?/1 calls Repo.exists?" do + assert {:ok, :exists?} = Repo.discarded?(Post) + end + + test "get_discarded/2 calls Repo.get" do + assert {:ok, :get, 123} = Repo.get_discarded(Post, 123) + end + + test "get_discarded!/2 calls Repo.get!" do + assert {:ok, :get!, 123} = Repo.get_discarded!(Post, 123) + end + + test "get_discarded_by/2 calls Repo.get_by" do + assert {:ok, :get_by, title: "Hello, World"} = + Repo.get_discarded_by(Post, title: "Hello, World") + end + + test "get_discarded_by!/2 calls Repo.get!" do + assert {:ok, :get_by!, title: "Hello, World"} = + Repo.get_discarded_by!(Post, title: "Hello, World") + end + + test "get_kept/2 calls Repo.get" do + assert {:ok, :get, 123} = Repo.get_kept(Post, 123) + end + + test "get_kept!/2 calls Repo.get!" do + assert {:ok, :get!, 123} = Repo.get_kept!(Post, 123) + end + + test "get_kept_by/2 calls Repo.get_by" do + assert {:ok, :get_by, title: "Hello, World"} = Repo.get_kept_by(Post, title: "Hello, World") + end + + test "get_kept_by!/2 calls Repo.get!" do + assert {:ok, :get_by!, title: "Hello, World"} = Repo.get_kept_by!(Post, title: "Hello, World") + end + + test "kept?/1" do + assert {:ok, :exists?} = Repo.kept?(Post) + end + + test "one_discarded/1 calls Repo.one" do + assert {:ok, :one} = Repo.one_discarded(Post) + end + + test "one_discarded!/1 calls Repo.one" do + assert {:ok, :one!} = Repo.one_discarded!(Post) + end + + test "one_kept/1 calls Repo.one" do + assert {:ok, :one} = Repo.one_kept(Post) + end + + test "one_kept!/1 calls Repo.one" do + assert {:ok, :one!} = Repo.one_kept!(Post) + end + + test "restore/1 updates struct" do + post = %Post{title: "Hello, World", discarded_at: DateTime.utc_now()} + + assert {:ok, updated} = Repo.restore(post) + refute updated.discarded_at + end + + test "restore/1 with changeset updates struct" do + post_changeset = + %Post{title: "Hello, World", discarded_at: DateTime.utc_now()} + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(:title, "Hello, Again") + + assert {:ok, updated} = Repo.restore(post_changeset) + refute updated.discarded_at + end + + test "restore!/1 with changeset raises error on failed update" do + post_changeset = + InvalidPost.changeset(%InvalidPost{title: "Hello, World"}, %{title: "Hello, Again"}) + + assert_raise Ecto.InvalidChangesetError, fn -> + Repo.restore!(post_changeset) + end + end +end diff --git a/test/trash/schema_test.exs b/test/trash/schema_test.exs new file mode 100644 index 0000000..955e255 --- /dev/null +++ b/test/trash/schema_test.exs @@ -0,0 +1,12 @@ +defmodule Trash.SchemaTest do + use ExUnit.Case + + alias Trash.Test.Post + + test "trashable_fields/0 adds trashable fields" do + post = %Post{} + + assert post.discarded? == nil + assert post.discarded_at == nil + end +end diff --git a/test/trash_test.exs b/test/trash_test.exs new file mode 100644 index 0000000..e83528e --- /dev/null +++ b/test/trash_test.exs @@ -0,0 +1,3 @@ +defmodule TrashTest do + use ExUnit.Case +end