{"id":16184515,"url":"https://github.com/searls/dry_eraser","last_synced_at":"2025-04-21T11:33:30.076Z","repository":{"id":229729756,"uuid":"777510715","full_name":"searls/dry_eraser","owner":"searls","description":"Like Active Record's validation feature, but for destroying models","archived":false,"fork":false,"pushed_at":"2024-05-16T22:00:35.000Z","size":33,"stargazers_count":29,"open_issues_count":1,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2025-04-16T05:17:19.843Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/searls.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2024-03-26T01:30:11.000Z","updated_at":"2024-05-15T17:13:35.000Z","dependencies_parsed_at":"2024-03-26T02:11:54.011Z","dependency_job_id":null,"html_url":"https://github.com/searls/dry_eraser","commit_stats":null,"previous_names":["searls/dry_eraser"],"tags_count":1,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/searls%2Fdry_eraser","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/searls%2Fdry_eraser/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/searls%2Fdry_eraser/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/searls%2Fdry_eraser/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/searls","download_url":"https://codeload.github.com/searls/dry_eraser/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":250048101,"owners_count":21366176,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-10T07:10:24.976Z","updated_at":"2025-04-21T11:33:29.790Z","avatar_url":"https://github.com/searls.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# dry_eraser – a _dry_ run before you _erase_ your models\n\n![dry_eraser](https://github.com/searls/dry_eraser/assets/79303/5dd8375e-c513-4f27-a90c-d74a2acaa62e)\n\nThis gem is for people who think it's weird that Rails offers so many ways to\nvalidate models before you create and update them, but all it gives you is a\n`before_destroy` hook before you permanently destroy them.\n\nThink of `dry_eraser` as adding a validation feature to `ActiveRecord#destroy`.\nTo that end, it defines `dry_erase` and `dry_erasable?` methods for your models,\nwhich behave analogously to `validates` and `valid?`, respectively. This way,\nyou won't need to register a `before_destroy` callback and then remember that\n`throw(:abort)` is the magical incantation needed to cancel the callback chain.\nIf you're suspicious of pulling in a dependency for something like this (and you\nshould be), the fact its [implementation is 50 lines soaking\nwet](lib/dry_eraser.rb) will hopefully put you at ease.\n\nHere's how to use it.\n\n## Install\n\nAdd it to your Gemfile:\n\n```ruby\ngem \"dry_eraser\"\n```\n\nThen run `bundle install`. That's it. Rails should load it automatically.\n\n## Usage\n\nWhenever there's a situation in which you know you _don't_ want to `destroy` a\nmodel, you can specify it by calling the `dry_erase` class method in the model's\nclass.\n\nLet's take an example `Whiteboard` model. Suppose it has a boolean attribute\ncalled `someone_wrote_do_not_erase_on_me` and you want to be sure `destroy`\noperations are aborted when that attribute is `true`.\n\nYou could:\n\n```ruby\nclass Whiteboard \u003c ActiveRecord::Base\n  dry_erase :no_one_said_not_to_erase_it\n\n  private\n\n  def no_one_said_not_to_erase_it\n    if someone_wrote_do_not_erase_on_me?\n      errors.add(:someone_wrote_do_not_erase_on_me, \"so I can't erase it\")\n    end\n  end\nend\n```\n\nThis way, whenever `someone_wrote_do_not_erase_on_me?` is true, `destroy` will\nreturn `false` (just like `save` returns false when validations fail).\n\nThis, combined with the fact that `dry_erase` determines success based on the\nabsence or presence of `errors` on the model instance will allow you to write\ncode that branches on whether destroy succeeded, just like you would for `save`\nor `update`:\n\n```ruby\nwhiteboard = Whiteboard.create!(someone_wrote_do_not_erase_on_me: true)\nif whiteboard.destroy\n  flash[:notice] = \"Whiteboard deleted!\"\n  redirect_to whiteboards_path\nelse\n  flash[:error] = whiteboard.errors.full_messages\n  render :show, status: :unprocessable_entity\nend\n```\n\nWant to know whether a model is can be safely destroyed before you `destroy` it?\nYou can also call `dry_erasable?` and it'll either return `true` or return\n`false` (and populate the `errors` object all the same):\n\n```ruby\nwhiteboard = Whiteboard.create!(someone_wrote_do_not_erase_on_me: true)\nwhiteboard.dry_erasable?\n=\u003e false\nwhiteboard.errors.full_messages.first\n=\u003e \"Someone wrote do not erase on me so I can't erase it\"\n```\n\nImportant consequence of this design: since `dry_eraser` mutates the same\n`errors` object as built-in validations do, calling `dry_erasable?` or `destroy`\nwill _clear_ the model's `errors` object first.\n\n## Other stuff you can pass to `dry_erase`\n\nThe `dry_erase` method can take one or more of any of the following:\n\n* A symbol or string name of an instance method on the model\n* A class that has a no-arg constructor and a `dry_erase(model)` method\n* An object that responds to a `dry_erase(model)`\n* An object (e.g. a proc or lambda) that responds to `call(model)`\n\nYou can see all of these uses in the gem's [test fixture](test/fixtures.rb):\n\n```ruby\n# You can specify multiple dry erasers at a time\ndry_erase :must_have_content, AnnoyingCoworkerMessageEraser\n\n# Or pass a lambda\ndry_erase -\u003e(model) { model.content == \"🖍️\" \u0026\u0026 model.errors.add(:base, \"No crayon, c'mon!\") }\n\n# Or an instance of a class (which allows it to receive static configuration in an initializer)\ndry_erase ForeignKeyEraser.new(Classroom, :whiteboard)\n```\n\nAnd that's about it.\n\n## A real-world example\n\nI'm currently developing an app for [my wife Becky's\nbusiness](http://www.betterwithbecky.com) and I'm modeling various\nstrength-training concepts. One model, `Movement`, depends on one or two pieces\nof `Equipment`. Becky should be able to delete equipment records, but only if they\naren't currently assigned to any movements.\n\nAs you might guess, this concern is enforced in the database with a foreign key,\nwhich was configured in the migration that defines the `movements` table.\nImagine something like this:\n\n```ruby\ncreate_table :movements do |t|\n  t.string :name, null: false\n\n  t.references :primary_equipment, foreign_key: {to_table: :equipments}, null: true\n  t.references :secondary_equipment, foreign_key: {to_table: :equipments}, null: true\n\n  t.timestamps\n  t.unique_constraint :name\n  end\nend\n```\n\nBecause `destroy` doesn't provide an easy way to run pre-flight validations, I\nfound myself writing a controller action like this on `EquipmentsController`:\n\n```ruby\ndef destroy\n  Equipment.find(params[:id]).destroy!\n  flash[:notice] = \"Equipment deleted!\"\n  redirect_to admin_equipments_path\nend\n```\n\nUsing a foreign key constraint and `destroy!` like this will indeed \"work\"\ninsofar as it will prevent `Movement` records from holding orphaned `Equipment`\nreferences, but instead of seeing a pleasant error message generated at the\napplication layer, the user (/my spouse) will either see some gobbledygook\ngenerated by Postgres or, worse, a generic 500 page.\n\nLet's use `dry_eraser` to make this nicer!\n\nAll we need to do is define a dry eraser on the Equipment model to prevent its\ndeletion when it's still associated with any movements.\n\nSince an `Equipment` can either fill a primary or secondary role in a `Movement`,\nthere are two foreign keys to consider as we use `dry_erase` to add what amounts\nto a pretty normal-looking validation method:\n\n```ruby\nclass Equipment \u003c ApplicationRecord\n  has_many :primary_movements, class_name: \"Movement\", foreign_key: \"primary_equipment_id\"\n  has_many :secondary_movements, class_name: \"Movement\", foreign_key: \"secondary_equipment_id\"\n\n  validates :name, presence: true, uniqueness: true\n\n  dry_erase :no_associated_movements\n\n  private\n\n  def no_associated_movements\n    if primary_movements.exists? || secondary_movements.exists?\n      errors.add(:base, \"Cannot destroy equipment because associated movements exist.\")\n    end\n  end\nend\n```\n\nOkay, now that we know destroy will abort when the operation is unsupported, we\ncan change our `destroy!` to `destroy` and wrap it in an `if`/`else` that will\nmore gracefully handle the situation in the user interface:\n\n```ruby\ndef destroy\n  @equipment = Equipment.find(params[:id])\n  if @equipment.destroy\n    flash[:notice] = \"Equipment deleted!\"\n    redirect_to admin_equipments_path\n  else\n    flash[:error] = @equipment.errors.full_messages\n    render :edit, status: :unprocessable_entity\n  end\nend\n```\n\nSquint and it looks like a `create` or `update` action.\n\nTo test that this is all working, we can throw up a link and see what happens\nwhen we try to delete an `Equipment` that's associated with a `Movement`:\n\n```erb\n\u003c%= link_to \"Delete equipment\",  admin_equipment_path(@equipment),\n  data: {\n    turbo_method: :delete,\n    turbo_confirm: \"Are you sure you want to delete this equipment?\"\n  }\n%\u003e\n```\n\n(The hardest part here is remembering that Rails 7 changed `data-confirm` to\n`data-turbo-confirm`.)\n\nAnyway, click that link, then click OK on the confirm dialog and… 🥁 drumroll 🥁…\n\n![A flash message explaining the deletion attempt was\ninvalid.](https://github.com/searls/dry_eraser/assets/79303/1ace01c6-2524-40e5-9f69-65542a9dc7f0)\n\nYahtzee! We did it! See, that wasn't so bad.\n\n## Extra credit assignment\n\nTo see a different way of accomplishing the same thing, we could also have\ncreated a class that took configuration values in an initializer and then handled\neach `destroy` attempt by implementing a `dry_erase(model)` method. Let's\nrefactor our approach to do that instead:\n\n```ruby\ndry_erase ForeignKeyEraser.new(association: :primary_movements)\ndry_erase ForeignKeyEraser.new(association: :secondary_movements)\n```\n\nAnd then we can implement that class anywhere we like:\n\n```ruby\nclass ForeignKeyEraser\n  def initialize(association:)\n    @association_name = association\n  end\n\n  def dry_erase(model)\n    if model.association(@association_name).scope.exists?\n      model.errors.add(:base, \"Cannot destroy #{model.model_name.human} because associated #{@association_name.to_s.humanize} exist.\")\n    end\n  end\nend\n```\n\nThe above reflects on the provided association name to look for existing records,\nbut we could have just as well taken a model and column name. Hopefully, this\ngives you the general idea.\n\nNow, let's make sure this works by trying to delete the equipment again:\n\n![Cannot destroy Equipment because associated Primary movements exist.](https://github.com/searls/dry_eraser/assets/79303/de691f8b-e82a-4db6-9c57-f62639b6936e)\n\nEven better!\n\nOkay, job's done. Happy erasing!\n\n## License\n\nThis one's an [MIT](/LICENSE.txt) joint.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsearls%2Fdry_eraser","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsearls%2Fdry_eraser","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsearls%2Fdry_eraser/lists"}