├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── test.exs └── test.exs.GH_actions ├── lib └── ecto │ ├── soft_delete_migration.ex │ ├── soft_delete_query.ex │ ├── soft_delete_repo.ex │ └── soft_delete_schema.ex ├── mix.exs ├── mix.lock └── test ├── soft_delete_migration_test.exs ├── soft_delete_query_test.exs ├── soft_delete_repo_test.exs ├── support ├── ecto_soft_delete_test_repo.ex └── postgres_types.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql], 3 | inputs: [ 4 | "*.{ex,exs}", 5 | "{config,lib,test}/**/*.{ex,exs}" 6 | ] 7 | ] 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for Elixir 4 | - package-ecosystem: mix 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: ["v*.*.*"] 6 | 7 | env: 8 | OTP_VERSION_SPEC: "25.3" 9 | ELIXIR_VERSION_SPEC: "1.13.4" 10 | 11 | jobs: 12 | test: 13 | uses: ./.github/workflows/test.yml 14 | publish: 15 | needs: test 16 | runs-on: ubuntu-20.04 17 | steps: 18 | - name: Checkout Repository 19 | uses: actions/checkout@v4 20 | 21 | - uses: erlef/setup-beam@v1 22 | with: 23 | otp-version: ${{ env.OTP_VERSION_SPEC }} 24 | elixir-version: ${{ env.ELIXIR_VERSION_SPEC }} 25 | 26 | # https://hex.pm/docs/publish 27 | - name: Publish 28 | env: 29 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 30 | run: | 31 | mix deps.get 32 | mix hex.publish --yes 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, workflow_call] 4 | 5 | env: 6 | MIX_ENV: test 7 | PGUSER: postgres 8 | POSTGRES_USER: postgres 9 | POSTGRES_PASSWORD: postgres 10 | POSTGRES_HOST: localhost 11 | POSTGRES_PORT: 5432 12 | 13 | jobs: 14 | test: 15 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 16 | runs-on: ubuntu-20.04 17 | strategy: 18 | matrix: 19 | include: 20 | - otp: "21" 21 | elixir: "1.11" 22 | - otp: "26" 23 | elixir: "1.16" 24 | services: 25 | postgres: 26 | env: 27 | POSTGRES_HOST_AUTH_METHOD: trust 28 | image: postgres:9.5 29 | ports: 30 | - 5432:5432 31 | options: >- 32 | --health-cmd pg_isready --health-interval 10s 33 | --health-timeout 5s --health-retries 5 34 | 35 | steps: 36 | - name: Checkout Repository 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up Elixir 40 | uses: erlef/setup-beam@v1 41 | with: 42 | otp-version: ${{matrix.otp}} 43 | elixir-version: ${{matrix.elixir}} 44 | 45 | - name: Run Tests 46 | run: | 47 | mix deps.get 48 | cp config/test.exs.GH_actions config/test.exs 49 | mix ecto.create 50 | mix test 51 | mix format --check-formatted 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | /.elixir_ls 20 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 25.3 2 | elixir 1.13.4 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.0.2] - 2020-08-12 9 | 10 | - Less strict elixir version requirement 11 | 12 | ## [2.0.1] - 2020-05-08 13 | 14 | - Add logic to `Ecto.SoftDelete.Repo.prepare_query` to respect `where` clauses that explicitly include records where deleted_at is not nil 15 | 16 | ## [2.0.0] - 2020-05-06 17 | 18 | - Exclude soft deleted records by default in `Ecto.SoftDelete.Repo` 19 | - **BREAKING**: Make `soft_delete_fields` use `utc_datetime_usec` instead of `utc_datetime` 20 | 21 | ## [1.1.0] - 2019-02-27 22 | 23 | ### Added 24 | 25 | - `Ecto.SoftDelete.Repo` for adding soft delete functions to repositories. 26 | 27 | ## [1.0.0] - 2019-02-15 28 | 29 | ### Added 30 | 31 | - Ecto 3 support 32 | 33 | ## [0.2.0] 34 | 35 | ### Fixed 36 | 37 | - Missing license (MIT) 38 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at support@revelry.co. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Setup for Development 2 | 3 | ``` 4 | git clone https://github.com/revelrylabs/ecto_soft_delete 5 | mix deps.get 6 | ``` 7 | 8 | # Submitting Changes 9 | 10 | 1. Fork the repository. 11 | 2. Set up the package per the instructions above and ensure `mix test` 12 | runs cleanly. 13 | 3. Create a topic branch. 14 | 4. Add specs for your unimplemented feature or bug fix. 15 | 5. Run `mix test`. If your specs pass, return to step 4. 16 | 6. Implement your feature or bug fix. 17 | 7. Re-run `mix test --cover`. If your specs fail, return to step 6. 18 | 8. Open cover/modules.html. If your changes are not completely covered by the 19 | test suite, return to Step 4. 20 | 9. Thoroughly document and comment your code. 21 | 10. Run `mix docs` and make sure your changes are documented. 22 | 11. Add, commit, and push your changes. 23 | 12. Submit a pull request. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Revelry Labs LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation 5 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 6 | and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions 9 | of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 12 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 13 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 14 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 15 | DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/revelrylabs/ecto_soft_delete/actions/workflows/test.yml/badge.svg) 2 | ![Publish Status](https://github.com/revelrylabs/ecto_soft_delete/actions/workflows/publish.yml/badge.svg) 3 | [![Hex.pm](https://img.shields.io/hexpm/dt/ecto_soft_delete.svg)](https://hex.pm/packages/ecto_soft_delete) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | # EctoSoftDelete 7 | 8 | Adds columns, fields, and queries for soft deletion with Ecto. 9 | 10 | [Documentation](https://hexdocs.pm/ecto_soft_delete) 11 | 12 | ## Usage 13 | 14 | ### Migrations 15 | 16 | In migrations for schemas to support soft deletion, import `Ecto.SoftDelete.Migration`. Next, add `soft_delete_columns()` when creating a table 17 | 18 | ```elixir 19 | defmodule MyApp.Repo.Migrations.CreateUser do 20 | use Ecto.Migration 21 | import Ecto.SoftDelete.Migration 22 | 23 | def change do 24 | create table(:users) do 25 | add :email, :string 26 | add :password, :string 27 | timestamps() 28 | soft_delete_columns() 29 | end 30 | end 31 | end 32 | ``` 33 | 34 | ### Schemas 35 | 36 | Import `Ecto.SoftDelete.Schema` into your Schema module, then add `soft_delete_schema()` to your schema 37 | 38 | ```elixir 39 | defmodule User do 40 | use Ecto.Schema 41 | import Ecto.SoftDelete.Schema 42 | 43 | schema "users" do 44 | field :email, :string 45 | soft_delete_schema() 46 | end 47 | end 48 | ``` 49 | 50 | If you want to make sure auto-filtering is disabled for a schema, set the `auto_exclude_from_queries?` option to false 51 | 52 | ```elixir 53 | defmodule User do 54 | use Ecto.Schema 55 | import Ecto.SoftDelete.Schema 56 | 57 | schema "users" do 58 | field :email, :string 59 | soft_delete_schema(auto_exclude_from_queries?: false) 60 | end 61 | end 62 | ``` 63 | 64 | ### Queries 65 | 66 | To query for items that have not been deleted, use `with_undeleted(query)` which will filter out deleted items using the `deleted_at` column produced by the previous 2 steps 67 | 68 | ```elixir 69 | import Ecto.SoftDelete.Query 70 | 71 | query = from(u in User, select: u) 72 | |> with_undeleted 73 | 74 | results = Repo.all(query) 75 | ``` 76 | 77 | ### Getting Deleted Rows 78 | 79 | To query for items that have been deleted, use `with_deleted: true` 80 | 81 | ```elixir 82 | import Ecto.Query 83 | 84 | query = from(u in User, select: u) 85 | 86 | results = Repo.all(query, with_deleted: true) 87 | ``` 88 | 89 | > [!IMPORTANT] 90 | > This only works for the topmost schema. If using `Ecto.SoftDelete.Repo`, rows fetched through associations (such as when using `Repo.preload/2`) will still be filtered. 91 | 92 | ## Repos 93 | 94 | To support deletion in repos, just add `use Ecto.SoftDelete.Repo` to your repo. 95 | After that, the functions `soft_delete!/1`, `soft_delete/1` and `soft_delete_all/1` will be available for you. 96 | 97 | ```elixir 98 | # repo.ex 99 | defmodule Repo do 100 | use Ecto.Repo, 101 | otp_app: :my_app, 102 | adapter: Ecto.Adapters.Postgres 103 | use Ecto.SoftDelete.Repo 104 | end 105 | 106 | # posts.ex 107 | Repo.soft_delete_all(Post) 108 | from(p in Post, where: p.id < 10) |> Repo.soft_delete_all() 109 | 110 | post = Repo.get!(Post, 42) 111 | case Repo.soft_delete post do 112 | {:ok, struct} -> # Soft deleted with success 113 | {:error, changeset} -> # Something went wrong 114 | end 115 | 116 | post = Repo.get!(Post, 42) 117 | struct = Repo.soft_delete!(post) 118 | ``` 119 | 120 | ### Using Options with Soft Delete Functions 121 | All soft delete functions support the same options as their Ecto counterparts: 122 | 123 | ```elixir 124 | # With schema prefix for multi-tenant databases 125 | Repo.soft_delete(post, prefix: "tenant_abc") 126 | Repo.soft_delete!(post, prefix: "tenant_abc") 127 | Repo.soft_delete_all(Post, prefix: "tenant_abc") 128 | ``` 129 | This allows for seamless integration with features like PostgreSQL schema prefixes for multi-tenancy. 130 | 131 | `Ecto.SoftDelete.Repo` will also intercept all queries made with the repo and automatically add a clause to filter out soft-deleted rows. 132 | 133 | ## Installation 134 | 135 | Add to mix.exs: 136 | 137 | ```elixir 138 | defp deps do 139 | [{:ecto_soft_delete, "~> 2.0"}] 140 | end 141 | ``` 142 | 143 | and do 144 | 145 | ``` 146 | mix deps.get 147 | ``` 148 | 149 | ## Configuration 150 | 151 | There are currently no configuration options. 152 | 153 | ## Usage 154 | 155 | ## Contributing 156 | 157 | Bug reports and pull requests are welcome on GitHub at https://github.com/revelrylabs/ecto_soft_delete. Check out [CONTRIBUTING.md](https://github.com/revelrylabs/ecto_soft_delete/blob/master/CONTRIBUTING.md) for more info. 158 | 159 | Everyone is welcome to participate in the project. We expect contributors to 160 | adhere the Contributor Covenant Code of Conduct (see [CODE_OF_CONDUCT.md](https://github.com/revelrylabs/ecto_soft_delete/blob/master/CODE_OF_CONDUCT.md)). 161 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :ecto_soft_delete, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:ecto_soft_delete, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ecto_soft_delete, ecto_repos: [Ecto.SoftDelete.Test.Repo] 4 | 5 | config :ecto_soft_delete, Ecto.SoftDelete.Test.Repo, 6 | database: "soft_delete_test", 7 | hostname: "localhost", 8 | port: 5432, 9 | pool: Ecto.Adapters.SQL.Sandbox, 10 | types: EctoSoftDelete.PostgresTypes 11 | -------------------------------------------------------------------------------- /config/test.exs.GH_actions: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :ecto_soft_delete, ecto_repos: [Ecto.SoftDelete.Test.Repo] 4 | 5 | config :ecto_soft_delete, Ecto.SoftDelete.Test.Repo, 6 | database: "soft_delete_test", 7 | hostname: "localhost", 8 | port: 5432, 9 | username: "postgres", 10 | adapter: Ecto.Adapters.Postgres, 11 | pool: Ecto.Adapters.SQL.Sandbox, 12 | types: EctoSoftDelete.PostgresTypes 13 | -------------------------------------------------------------------------------- /lib/ecto/soft_delete_migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Migration do 2 | @moduledoc """ 3 | Contains functions to add soft delete columns to a table during migrations 4 | """ 5 | 6 | use Ecto.Migration 7 | 8 | @doc """ 9 | Adds deleted_at column to a table. This column is used to track if an item is deleted or not and when 10 | 11 | defmodule MyApp.Repo.Migrations.CreateUser do 12 | use Ecto.Migration 13 | import Ecto.SoftDelete.Migration 14 | 15 | def change do 16 | create table(:users) do 17 | add :email, :string 18 | add :password, :string 19 | timestamps() 20 | soft_delete_columns() 21 | end 22 | end 23 | end 24 | 25 | """ 26 | def soft_delete_columns do 27 | add(:deleted_at, :utc_datetime_usec, []) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/ecto/soft_delete_query.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Query do 2 | @moduledoc """ 3 | functions for querying data that is (or is not) soft deleted 4 | """ 5 | 6 | import Ecto.Query 7 | 8 | @doc """ 9 | Returns a query that searches only for undeleted items 10 | 11 | query = from(u in User, select: u) 12 | |> with_undeleted 13 | 14 | results = Repo.all(query) 15 | 16 | """ 17 | @spec with_undeleted(Ecto.Queryable.t()) :: Ecto.Queryable.t() 18 | def with_undeleted(query) do 19 | if soft_deletable?(query) do 20 | query 21 | |> where([t], is_nil(t.deleted_at)) 22 | else 23 | query 24 | end 25 | end 26 | 27 | @doc """ 28 | Returns `true` if the query is soft deletable, `false` otherwise. 29 | 30 | query = from(u in User, select: u) 31 | |> soft_deletable? 32 | 33 | """ 34 | @spec soft_deletable?(Ecto.Queryable.t()) :: boolean() 35 | def soft_deletable?(query) do 36 | schema_module = get_schema_module(query) 37 | fields = if schema_module, do: schema_module.__schema__(:fields), else: [] 38 | 39 | Enum.member?(fields, :deleted_at) 40 | end 41 | 42 | @doc """ 43 | Returns `true` if the schema is not flagged to skip auto-filtering 44 | """ 45 | @spec auto_include_deleted_at_clause?(Ecto.Queriable.t()) :: boolean() 46 | def auto_include_deleted_at_clause?(query) do 47 | schema_module = get_schema_module(query) 48 | 49 | !Kernel.function_exported?(schema_module, :skip_soft_delete_prepare_query?, 0) || 50 | !schema_module.skip_soft_delete_prepare_query?() 51 | end 52 | 53 | defp get_schema_module({_raw_schema, module}) when not is_nil(module), do: module 54 | defp get_schema_module(%Ecto.Query{from: %{source: source}}), do: get_schema_module(source) 55 | defp get_schema_module(%Ecto.SubQuery{query: query}), do: get_schema_module(query) 56 | defp get_schema_module(_), do: nil 57 | end 58 | -------------------------------------------------------------------------------- /lib/ecto/soft_delete_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Repo do 2 | @moduledoc """ 3 | Adds soft delete functions to an repository. 4 | 5 | defmodule Repo do 6 | use Ecto.Repo, 7 | otp_app: :my_app, 8 | adapter: Ecto.Adapters.Postgres 9 | use Ecto.SoftDelete.Repo 10 | end 11 | 12 | """ 13 | 14 | @doc """ 15 | Soft deletes all entries matching the given query. 16 | 17 | It returns a tuple containing the number of entries and any returned 18 | result as second element. The second element is `nil` by default 19 | unless a `select` is supplied in the update query. 20 | 21 | ## Options 22 | 23 | All options supported by `c:Ecto.Repo.update_all/3` can be used. 24 | 25 | ## Examples 26 | 27 | MyRepo.soft_delete_all(Post) 28 | from(p in Post, where: p.id < 10) |> MyRepo.soft_delete_all() 29 | 30 | # With schema prefix for multi-tenant databases 31 | MyRepo.soft_delete_all(Post, prefix: "tenant_abc") 32 | 33 | """ 34 | @callback soft_delete_all(queryable :: Ecto.Queryable.t(), opts :: Keyword.t()) :: 35 | {integer, nil | [term()]} 36 | 37 | @doc """ 38 | Soft deletes a struct. 39 | Updates the `deleted_at` field with the current datetime in UTC. 40 | It returns `{:ok, struct}` if the struct has been successfully 41 | soft deleted or `{:error, changeset}` if there was a validation 42 | or a known constraint error. 43 | 44 | ## Options 45 | 46 | All options supported by `c:Ecto.Repo.update/2` can be used. 47 | 48 | ## Examples 49 | 50 | post = MyRepo.get!(Post, 42) 51 | case MyRepo.soft_delete post do 52 | {:ok, struct} -> # Soft deleted with success 53 | {:error, changeset} -> # Something went wrong 54 | end 55 | 56 | # With schema prefix for multi-tenant databases 57 | MyRepo.soft_delete(post, prefix: "tenant_abc") 58 | 59 | """ 60 | @callback soft_delete( 61 | struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(), 62 | opts :: Keyword.t() 63 | ) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 64 | 65 | @doc """ 66 | Same as `c:soft_delete/2` but returns the struct or raises if the changeset is invalid. 67 | """ 68 | @callback soft_delete!( 69 | struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(), 70 | opts :: Keyword.t() 71 | ) :: Ecto.Schema.t() 72 | 73 | defmacro __using__(_opts) do 74 | quote do 75 | import Ecto.Query 76 | import Ecto.SoftDelete.Query 77 | 78 | def soft_delete_all(queryable, opts \\ []) do 79 | set_expr = [set: [deleted_at: DateTime.utc_now()]] 80 | update_all(queryable, set_expr, opts) 81 | end 82 | 83 | # Define soft_delete and soft_delete! with options 84 | def soft_delete(struct_or_changeset, opts \\ []) do 85 | struct_or_changeset 86 | |> Ecto.Changeset.change(deleted_at: DateTime.utc_now()) 87 | |> __MODULE__.update(opts) 88 | end 89 | 90 | def soft_delete!(struct_or_changeset, opts \\ []) do 91 | struct_or_changeset 92 | |> Ecto.Changeset.change(deleted_at: DateTime.utc_now()) 93 | |> __MODULE__.update!(opts) 94 | end 95 | 96 | @doc """ 97 | Overrides all query operations to exclude soft deleted records 98 | if the schema in the from clause has a deleted_at column 99 | NOTE: will not exclude soft deleted records if :with_deleted option passed as true 100 | """ 101 | def prepare_query(_operation, query, opts) do 102 | skip_deleted_at_clause? = 103 | has_include_deleted_at_clause?(query) || 104 | opts[:with_deleted] || 105 | !soft_deletable?(query) || 106 | !auto_include_deleted_at_clause?(query) 107 | 108 | if skip_deleted_at_clause? do 109 | {query, opts} 110 | else 111 | query = from(x in query, where: is_nil(x.deleted_at)) 112 | {query, opts} 113 | end 114 | end 115 | 116 | # Checks the query to see if it contains a where not is_nil(deleted_at) 117 | # if it does, we want to be sure that we don't exclude soft deleted records 118 | defp has_include_deleted_at_clause?(%Ecto.Query{wheres: wheres}) do 119 | Enum.any?(wheres, fn %{expr: expr} -> 120 | expr 121 | |> Inspect.Algebra.to_doc(%Inspect.Opts{ 122 | inspect_fun: fn expr, _ -> 123 | inspect(expr, limit: :infinity) 124 | end 125 | }) 126 | |> String.contains?( 127 | "{:not, [], [{:is_nil, [], [{{:., [], [{:&, [], [0]}, :deleted_at]}, [], []}]}]}" 128 | ) 129 | end) 130 | end 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/ecto/soft_delete_schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Schema do 2 | @moduledoc """ 3 | Contains schema macros to add soft delete fields to a schema 4 | """ 5 | 6 | @doc """ 7 | Adds the deleted_at column to a schema 8 | 9 | defmodule User do 10 | use Ecto.Schema 11 | import Ecto.SoftDelete.Schema 12 | 13 | schema "users" do 14 | field :email, :string 15 | soft_delete_schema() 16 | end 17 | end 18 | 19 | Options: 20 | - `:auto_exclude_from_queries?` - If false, Ecto.SoftDelete.Repo won't 21 | automatically add the necessary clause to filter out soft-deleted rows. See 22 | `Ecto.SoftDelete.Repo.prepare_query` for more info. Defaults to `true`. 23 | 24 | """ 25 | defmacro soft_delete_schema(opts \\ []) do 26 | filter_tag_definition = 27 | unless Keyword.get(opts, :auto_exclude_from_queries?, true) do 28 | quote do 29 | def skip_soft_delete_prepare_query?, do: true 30 | end 31 | end 32 | 33 | quote do 34 | field(:deleted_at, :utc_datetime_usec) 35 | unquote(filter_tag_definition) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoSoftDelete.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_soft_delete, 7 | version: "2.1.0", 8 | elixir: "~> 1.11", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | build_embedded: Mix.env() == :prod, 11 | start_permanent: Mix.env() == :prod, 12 | test_coverage: [tool: ExCoveralls], 13 | preferred_cli_env: [ 14 | coveralls: :test, 15 | "coveralls.detail": :test, 16 | "coveralls.post": :test, 17 | "coveralls.html": :test 18 | ], 19 | deps: deps(), 20 | package: package(), 21 | description: description() 22 | ] 23 | end 24 | 25 | def application do 26 | [extra_applications: [:logger]] 27 | end 28 | 29 | defp elixirc_paths(:test), do: ["lib", "test/support"] 30 | defp elixirc_paths(_), do: ["lib"] 31 | 32 | defp deps do 33 | [ 34 | {:ecto_sql, "~> 3.5"}, 35 | {:postgrex, ">= 0.0.0", only: [:test]}, 36 | {:ex_doc, "~> 0.16", only: [:dev, :test]}, 37 | {:credo, "~> 1.0", only: [:dev, :test]}, 38 | {:excoveralls, "~> 0.8", only: [:dev, :test]} 39 | ] 40 | end 41 | 42 | defp description do 43 | """ 44 | Soft deletion with Ecto. 45 | """ 46 | end 47 | 48 | defp package do 49 | [ 50 | files: ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"], 51 | maintainers: ["Bryan Joseph", "Luke Ledet", "Revelry Labs"], 52 | licenses: ["MIT"], 53 | links: %{ 54 | "GitHub" => "https://github.com/revelrylabs/ecto_soft_delete" 55 | }, 56 | build_tools: ["mix"] 57 | ] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 3 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 4 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 5 | "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, 6 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 7 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 8 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, 10 | "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, 11 | "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 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", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, 12 | "ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [: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", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"}, 13 | "excoveralls": {:hex, :excoveralls, "0.18.2", "86efd87a0676a3198ff50b8c77620ea2f445e7d414afa9ec6c4ba84c9f8bdcc2", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "230262c418f0de64077626a498bd4fdf1126d5c2559bb0e6b43deac3005225a4"}, 14 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 15 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 16 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, 17 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 18 | "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, 19 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], [], "hexpm"}, 20 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 21 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 22 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 23 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 24 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 25 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 26 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 27 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 28 | "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, 29 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, 30 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 31 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 32 | } 33 | -------------------------------------------------------------------------------- /test/soft_delete_migration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Migration.Test do 2 | use ExUnit.Case 3 | use Ecto.Migration 4 | alias Ecto.SoftDelete.Test.Repo 5 | import Ecto.SoftDelete.Migration 6 | alias Ecto.Migration.Runner 7 | 8 | setup meta do 9 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) 10 | 11 | {:ok, runner} = 12 | Runner.start_link( 13 | {self(), Repo, Repo.config(), __MODULE__, meta[:direction] || :forward, :up, 14 | %{level: false, sql: false}} 15 | ) 16 | 17 | Runner.metadata(runner, meta) 18 | {:ok, runner: runner} 19 | end 20 | 21 | test "soft_delete_columns adds deleted_at column", %{runner: runner} do 22 | create table(:posts, primary_key: false) do 23 | soft_delete_columns() 24 | end 25 | 26 | [create_command] = Agent.get(runner, & &1.commands) 27 | 28 | flush() 29 | 30 | assert {:create, _, [{:add, :deleted_at, :utc_datetime_usec, []}]} = create_command 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/soft_delete_query_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Query.Test do 2 | use ExUnit.Case 3 | import Ecto.Query 4 | import Ecto.SoftDelete.Query 5 | alias Ecto.SoftDelete.Test.Repo 6 | 7 | defmodule User do 8 | use Ecto.Schema 9 | import Ecto.SoftDelete.Schema 10 | 11 | schema "users" do 12 | field(:email, :string) 13 | soft_delete_schema() 14 | end 15 | end 16 | 17 | defmodule Nondeletable do 18 | use Ecto.Schema 19 | 20 | schema "nondeletable" do 21 | field(:value, :string) 22 | end 23 | end 24 | 25 | setup do 26 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) 27 | end 28 | 29 | test "deleted_at in schema" do 30 | defmodule Something do 31 | use Ecto.Schema 32 | import Ecto.SoftDelete.Schema 33 | 34 | schema "users" do 35 | field(:email, :string) 36 | soft_delete_schema() 37 | end 38 | end 39 | 40 | assert :deleted_at in Something.__schema__(:fields) 41 | end 42 | 43 | test "User has deleted_at field" do 44 | Repo.insert!(%User{email: "test@example.com"}) 45 | query = from(u in User, select: u) 46 | results = Repo.all(query) 47 | 48 | assert nil == hd(results).deleted_at 49 | end 50 | 51 | test "with_undeleted on returns undeleted users" do 52 | Repo.insert!(%User{email: "undeleted@example.com"}) 53 | 54 | Repo.insert!(%User{ 55 | email: "deleted@example.com", 56 | deleted_at: DateTime.utc_now() 57 | }) 58 | 59 | query = 60 | from(u in User, select: u) 61 | |> with_undeleted 62 | 63 | results = Repo.all(query) 64 | 65 | assert 1 == length(results) 66 | assert "undeleted@example.com" == hd(results).email 67 | end 68 | 69 | test "with_undeleted returns the same query when the schema is not soft deletable" do 70 | query = from(n in Nondeletable, select: n) 71 | 72 | assert query == with_undeleted(query) 73 | end 74 | 75 | test "with_undeleted on subquery returns undeleted users" do 76 | Repo.insert!(%User{email: "undeleted@example.com"}) 77 | 78 | Repo.insert!(%User{ 79 | email: "deleted@example.com", 80 | deleted_at: DateTime.utc_now() 81 | }) 82 | 83 | query = 84 | from(u in User, select: u) 85 | |> subquery() 86 | |> select([u], count(u)) 87 | |> with_undeleted 88 | 89 | results = Repo.one(query) 90 | 91 | assert 1 == results 92 | end 93 | 94 | test "soft_deletable? returns true when the schema module has a deleted_at field" do 95 | query = from(u in User, select: u) 96 | 97 | assert soft_deletable?(query) 98 | end 99 | 100 | test "soft_deletable? returns false when the schema module does not have a deleted_at field" do 101 | query = from(n in Nondeletable, select: n) 102 | 103 | refute soft_deletable?(query) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/soft_delete_repo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Repo.Test do 2 | use ExUnit.Case 3 | alias Ecto.SoftDelete.Test.Repo 4 | import Ecto.Query 5 | import ExUnit.CaptureLog 6 | 7 | defmodule User do 8 | use Ecto.Schema 9 | import Ecto.SoftDelete.Schema 10 | 11 | schema "users" do 12 | field(:email, :string) 13 | soft_delete_schema() 14 | end 15 | end 16 | 17 | defmodule UserWithSkipPrepareQuery do 18 | use Ecto.Schema 19 | import Ecto.SoftDelete.Schema 20 | 21 | schema "users" do 22 | field(:email, :string) 23 | soft_delete_schema(auto_exclude_from_queries?: false) 24 | end 25 | end 26 | 27 | defmodule Nondeletable do 28 | use Ecto.Schema 29 | 30 | schema "nondeletable" do 31 | field(:value, :string) 32 | end 33 | end 34 | 35 | setup do 36 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Ecto.SoftDelete.Test.Repo) 37 | end 38 | 39 | describe "soft_delete!/1" do 40 | test "should soft delete the queryable" do 41 | user = Repo.insert!(%User{email: "test0@example.com"}) 42 | 43 | assert %User{} = Repo.soft_delete!(user) 44 | 45 | assert %DateTime{} = 46 | Repo.get_by!(User, [email: "test0@example.com"], with_deleted: true).deleted_at 47 | end 48 | 49 | test "should return an error deleting" do 50 | user = Repo.insert!(%User{email: "test0@example.com"}) 51 | Repo.delete!(user) 52 | 53 | assert_raise Ecto.StaleEntryError, fn -> 54 | Repo.soft_delete!(user) 55 | end 56 | end 57 | end 58 | 59 | describe "soft_delete!/2 with options" do 60 | test "should soft delete the queryable and pass options to update!" do 61 | user = Repo.insert!(%User{email: "test0@example.com"}) 62 | 63 | # Test with empty options list 64 | assert %User{} = Repo.soft_delete!(user, []) 65 | 66 | assert %DateTime{} = 67 | Repo.get_by!(User, [email: "test0@example.com"], with_deleted: true).deleted_at 68 | end 69 | end 70 | 71 | describe "soft_delete/1" do 72 | test "should soft delete the queryable" do 73 | user = Repo.insert!(%User{email: "test0@example.com"}) 74 | 75 | assert {:ok, %User{}} = Repo.soft_delete(user) 76 | 77 | assert %DateTime{} = 78 | Repo.get_by!(User, [email: "test0@example.com"], with_deleted: true).deleted_at 79 | end 80 | 81 | test "should return an error deleting" do 82 | user = Repo.insert!(%User{email: "test0@example.com"}) 83 | Repo.delete!(user) 84 | 85 | assert_raise Ecto.StaleEntryError, fn -> 86 | Repo.soft_delete(user) 87 | end 88 | end 89 | end 90 | 91 | describe "soft_delete/2 with options" do 92 | test "should soft delete the queryable and pass options to update" do 93 | user = Repo.insert!(%User{email: "test0@example.com"}) 94 | 95 | # Test with empty options list 96 | assert {:ok, %User{}} = Repo.soft_delete(user, []) 97 | 98 | assert %DateTime{} = 99 | Repo.get_by!(User, [email: "test0@example.com"], with_deleted: true).deleted_at 100 | end 101 | end 102 | 103 | describe "soft_delete_all/1" do 104 | test "soft deleted the query" do 105 | Repo.insert!(%User{email: "test0@example.com"}) 106 | Repo.insert!(%User{email: "test1@example.com"}) 107 | Repo.insert!(%User{email: "test2@example.com"}) 108 | 109 | assert Repo.soft_delete_all(User) == {3, nil} 110 | 111 | assert User |> Repo.all(with_deleted: true) |> length() == 3 112 | 113 | assert %DateTime{} = 114 | Repo.get_by!(User, [email: "test0@example.com"], with_deleted: true).deleted_at 115 | 116 | assert %DateTime{} = 117 | Repo.get_by!(User, [email: "test1@example.com"], with_deleted: true).deleted_at 118 | 119 | assert %DateTime{} = 120 | Repo.get_by!(User, [email: "test2@example.com"], with_deleted: true).deleted_at 121 | end 122 | 123 | test "when no results are found" do 124 | assert Repo.soft_delete_all(User) == {0, nil} 125 | end 126 | end 127 | 128 | describe "soft_delete_all/2 with options" do 129 | test "soft deletes the query and passes options to update_all" do 130 | Repo.insert!(%User{email: "test0@example.com"}) 131 | Repo.insert!(%User{email: "test1@example.com"}) 132 | 133 | # Test with empty options list 134 | assert Repo.soft_delete_all(User, []) == {2, nil} 135 | 136 | assert User |> Repo.all(with_deleted: true) |> length() == 2 137 | 138 | assert %DateTime{} = 139 | Repo.get_by!(User, [email: "test0@example.com"], with_deleted: true).deleted_at 140 | 141 | assert %DateTime{} = 142 | Repo.get_by!(User, [email: "test1@example.com"], with_deleted: true).deleted_at 143 | end 144 | 145 | test "soft deletes with log option" do 146 | Repo.insert!(%User{email: "test0@example.com"}) 147 | 148 | log = 149 | capture_log(fn -> 150 | Repo.soft_delete_all(User, log: :info) 151 | end) 152 | 153 | assert log =~ "UPDATE" 154 | assert log =~ "deleted_at" 155 | end 156 | end 157 | 158 | describe "prepare_query/3" do 159 | test "excludes soft deleted records by default" do 160 | user = Repo.insert!(%User{email: "test0@example.com"}) 161 | 162 | soft_deleted_user = 163 | Repo.insert!(%User{email: "deleted@example.com", deleted_at: DateTime.utc_now()}) 164 | 165 | results = User |> Repo.all() 166 | 167 | assert Enum.member?(results, user) 168 | refute Enum.member?(results, soft_deleted_user) 169 | end 170 | 171 | test "includes soft deleted records if :with_deleted option is present" do 172 | user = Repo.insert!(%User{email: "test0@example.com"}) 173 | 174 | soft_deleted_user = 175 | Repo.insert!(%User{email: "deleted@example.com", deleted_at: DateTime.utc_now()}) 176 | 177 | results = User |> Repo.all(with_deleted: true) 178 | 179 | assert Enum.member?(results, user) 180 | assert Enum.member?(results, soft_deleted_user) 181 | end 182 | 183 | test "includes soft deleted records if where not is_nil(deleted_at) clause is present" do 184 | user = Repo.insert!(%User{email: "test0@example.com"}) 185 | 186 | soft_deleted_user = 187 | Repo.insert!(%User{email: "deleted@example.com", deleted_at: DateTime.utc_now()}) 188 | 189 | results = 190 | User 191 | |> where([u], not is_nil(u.deleted_at)) 192 | |> Repo.all() 193 | 194 | refute Enum.member?(results, user) 195 | assert Enum.member?(results, soft_deleted_user) 196 | end 197 | 198 | test "includes soft deleted records if `auto_exclude_from_queries?` is false" do 199 | user = Repo.insert!(%UserWithSkipPrepareQuery{email: "test0@example.com"}) 200 | 201 | soft_deleted_user = 202 | Repo.insert!(%UserWithSkipPrepareQuery{ 203 | email: "deleted@example.com", 204 | deleted_at: DateTime.utc_now() 205 | }) 206 | 207 | results = UserWithSkipPrepareQuery |> Repo.all() 208 | 209 | assert Enum.member?(results, user) 210 | assert Enum.member?(results, soft_deleted_user) 211 | end 212 | 213 | test "works with schemas that don't have deleted_at column" do 214 | Repo.insert!(%Nondeletable{value: "stuff"}) 215 | results = Nondeletable |> Repo.all() 216 | 217 | assert length(results) == 1 218 | end 219 | 220 | test "returns same result for different types of where clauses" do 221 | _user = Repo.insert!(%User{email: "test0@example.com"}) 222 | 223 | _soft_deleted_user = 224 | Repo.insert!(%User{email: "deleted@example.com", deleted_at: DateTime.utc_now()}) 225 | 226 | query_1 = 227 | from(u in User, 228 | select: u, 229 | where: u.email == "test0@example.com" and not is_nil(u.deleted_at) 230 | ) 231 | 232 | query_2 = 233 | from(u in User, 234 | select: u, 235 | where: u.email == "test0@example.com", 236 | where: not is_nil(u.deleted_at) 237 | ) 238 | 239 | assert Repo.all(query_2) == Repo.all(query_1) 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /test/support/ecto_soft_delete_test_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.SoftDelete.Test.Repo do 2 | use Ecto.Repo, otp_app: :ecto_soft_delete, adapter: Ecto.Adapters.Postgres 3 | use Ecto.SoftDelete.Repo 4 | end 5 | -------------------------------------------------------------------------------- /test/support/postgres_types.ex: -------------------------------------------------------------------------------- 1 | Postgrex.Types.define(EctoSoftDelete.PostgresTypes, [], []) 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = Application.ensure_all_started(:postgrex) 2 | {:ok, _pid} = Ecto.SoftDelete.Test.Repo.start_link() 3 | 4 | defmodule Ecto.SoftDelete.Test.Migrations do 5 | use Ecto.Migration 6 | import Ecto.SoftDelete.Migration 7 | 8 | def change do 9 | drop_if_exists table(:users) 10 | 11 | create table(:users) do 12 | add :email, :string 13 | soft_delete_columns() 14 | end 15 | 16 | create table(:nondeletable) do 17 | add :value, :string 18 | end 19 | end 20 | end 21 | 22 | _ = Ecto.Migrator.up(Ecto.SoftDelete.Test.Repo, 0, Ecto.SoftDelete.Test.Migrations, log: false) 23 | ExUnit.start() 24 | 25 | Ecto.Adapters.SQL.Sandbox.mode(Ecto.SoftDelete.Test.Repo, :manual) 26 | --------------------------------------------------------------------------------