Skip to content

Gem for saving drafts of ActiveRecord models, and later approving those drafts to apply the changes

License

Notifications You must be signed in to change notification settings

38degrees/draft_approve

Repository files navigation

DraftApprove

Table of Contents

Introduction

DraftApprove is a Ruby gem which lets you save draft changes of your ActiveRecord models to your database. It allows grouping of related changes into a 'Draft Transaction' which must be approved or rejected as a whole, rather than allowing individual draft changes to be applied independently.

There are a number of other similar Ruby gems available for drafting changes to ActiveRecord models. Depending upon your projects needs, another gem may be more suitable. See the Alternative Drafting Gems section for full details.

The specific features / functionality offered by DraftApprove are:

  • No changes are needed to your existing database tables
  • No updates are required to your existing ActiveRecord queries or raw SQL queries
  • It is possible to save drafts of new records, save draft updates to existing records, and save draft deletions of records
  • Multiple related draft changes (new records, updates, deletions) may be grouped together in a 'Draft Transaction' which must then be approved or rejected as a whole
    • This includes being able to save a draft of a model which references an unsaved model - as long as that unsaved model already has a draft
  • Each model may only have one pending draft at a time

Installation

Add this line to your application's Gemfile:

gem 'draft_approve'

And then execute:

$ bundle

Or install it yourself as:

$ gem install draft_approve

Once installed, you must generate the migration to create the required draft tables in your database, and run the migration:

$ rails generate draft_approve:migration
$ rails db:migrate

Usage

Make your Models draftable

Add acts_as_draftable to all models you'd like to be draftable. For example:

class Person < ActiveRecord::Base
  has_many :contact_addresses
  acts_as_draftable
end

class ContactAddress < ActiveRecord::Base
  belongs_to :person
  acts_as_draftable
end

Create a draft for a single object

Call draft_save! to save a draft of a new model, or save draft changes to an existing model.

Call draft_destroy! to draft the deletion of the model.

There are also convenience methods draft_create! and draft_update!.

For example:

### CREATE EXAMPLES

# Save draft of a new model
person = Person.new(name: 'new person')
draft = person.draft_save!

# Short-hand to save draft of a new model
draft = Person.draft_create!(name: 'new person')

### UPDATE EXAMPLES

# Save draft changes to an existing person
person = Person.find(1)
person.name = 'update existing person'
draft = person.draft_save!

# Short-hand to save draft changes to an existing person
draft = person.draft_update!(name: 'update existing person')

### DELETE EXAMPLES

# Draft delete an existing person
person = Person.find(2)
draft = person.draft_destroy!

Create multiple related drafts

If you want to ensure multiple related changes are all approved, or all rejected, as a single block, use a Draft Transaction. You do this by calling the draft_transaction method on any draftable model class, and passing it a block where all your drafts are saved. You use the same draft_save! and draft_destroy! methods within the Draft Transaction.

For example:

draft_transaction = Person.draft_transaction do
  # Want reference to person object, so don't use shorthand draft_create! method
  person = Person.new(name: 'new person name')
  person.draft_save!

  existing_contact_address = ContactAddress.find(1)
  existing_contact_address.draft_update!(person: person)

  ContactAddress.find(2).draft_destroy!
end

This would create 3 drafts (one to create a new person, one to update an existing contact address, and one to delete a different contact address). These must all be applied together, or all be rejected.

Approve drafts

Regardless of how a draft was created, a Draft Transaction is always created, and the Draft Transaction is what needs to be approved. This will apply the changes in all drafts within the Draft Transaction (which may only be one draft).

For example:

# If you have reference to a Draft object
draft.draft_transaction.approve_changes!(reviewed_by: 'my_username', review_reason: 'Looks Good!')

# If you have reference to a DraftTransaction object
draft_transaction.approve_changes!(reviewed_by: 'my_username', review_reason: 'Looks Good!')

Reject drafts

This will reject all changes in all drafts within the Draft Transaction (which may only be one draft).

For example:

# If you have reference to a Draft object
draft.draft_transaction.reject_changes!(reviewed_by: 'my_username', review_reason: 'Nope!')

# If you have reference to a DraftTransaction object
draft_transaction.reject_changes!(reviewed_by: 'my_username', review_reason: 'Nope!')

Find drafts pending approval

As discussed, all drafts are created inside a Draft Transaction, and it is these which must be approved or rejected.

You can find all Draft Transactions with a particular status using the following methods:

pending_draft_transactions = DraftTransaction.pending_approval

approved_draft_transactions = DraftTransaction.approved

rejected_draft_transactions = DraftTransaction.rejected

Errors

If an error occurs while approving a transaction, the error will cause the transaction to fail, so none of the draft changes will be applied. The Draft Transaction will have its status set to approval_error, and its error column will contain more information (the error and the backtrace).

All Draft Transactions with an error can be found using the following:

errored_draft_transactions = DraftTransaction.approval_error

Advanced usage

Who created a draft?

When creating a Draft Transaction, you may pass in a created_by string. This could be a username or the name of an automated process, and will be stored in the DraftTransaction.created_by column in the database. This option is only available when saving drafts within an explicit Draft Transaction.

For example:

draft_transaction = Person.draft_transaction(created_by: 'UserA') do
  Person.new(name: 'new person name').draft_save!
end

Extra metadata for drafts

When creating a Draft Transaction, you may pass in an extra_data hash. This can contain anything, and will be stored in the DraftTransaction.extra_data column in the database. This option is only available when saving drafts within an explicit Draft Transaction.

Possible use-cases for the extra data hash are storing which users or roles are allowed to approve these drafts, storing additional data about why or how the drafts were created, etc. The DraftApprove gem does not implement these features for you (eg. limiting who can approve drafts), but simply gives you a way to store generic metadata about a Draft Transaction should you wish to build such features within your application logic.

For example:

extra_data = {
  'can_be_approved_by' => ['SuperAdminRole', 'UserB'],
  'data_source_url' => 'https://en.wikipedia.org/wiki/RubyGems',
  'data_scraped_at' => '2019-02-08 12:00:00'
}

draft_transaction = Person.draft_transaction(extra_data: extra_data) do
  Person.new(name: 'new person name').draft_save!
end

Skipping validations when saving drafts

By default, models will have their ActiveRecords validations checked before a draft is saved. This prevents invalid drafts from being persisted, which would just fail validation when the Draft Transaction is approved anyway.

Side note - when saving a draft, only ActiveRecord validations are checked. Since the draft data is not written to your application table, database-only validations cannot be checked!

If you would like to skip checking ActiveRecord validations when saving a draft, you may pass the validate: false option to draft_save, for example:

person = Person.new
person.draft_save!(validate: false)

Validations will still run when the draft is approved, so this option is not especially useful unless combined with a custom method for creating or updating the record (see below).

Custom methods for creating, updating and deleting data

When a Draft Transaction is approved, all drafts within the transaction are applied, meaning the changes within the draft are made live on the database. This is acheived by calling suitable ActiveRecord methods. The default methods used by the DraftApprove gem are:

  • create! for new models saved with draft_save!
  • update! for existing models which have been modified and saved with draft_save!
  • destroy! for models which have had draft_destroy! called on them

Note that create! is a class level ActiveRecord method, while update! and destroy! are instance level ActiveRecord methods.

When saving drafts, you may override the method used to save the changes by passing an options hash to the draft_save! or draft_destroy! methods. You are not able to do this with the convenience draft_create! or draft_update! methods.

For example:

draft_transaction = Person.draft_transaction do
  # When approved, find or create Person A
  person = Person.new(name: 'Person A')
  person.draft_save!(create_method: :find_or_create_by!)

  # When approved, update the record ignoring validations
  existing_person = Person.find(1)
  existing_person.birth_date = '1800-01-01'
  existing_person.draft_save!(update_method: :update_columns)

  # When approved, delete the record directly in the database without any ActiveRecord callbacks
  Person.find(2).draft_destroy!(delete_method: :delete)
end

CAUTION

  • No validation is done to check you are using sensible alternative methods, so use at your own risk!
  • It is strongly recommended to use methods which will raise an error if they fail, otherwise one draft in a Draft Transaction may 'silently' fail, causing subsequent drafts to be applied, and the Draft Transaction as a whole may appear to have been successfully approved & applied
  • Methods used as the create_method must be class methods for the model you are drafting, which accept a hash of attribute names to attribute values (eg. Person.create!, Person.find_or_create_by!, etc)
  • Methods used as the update_method must be instance methods for the model you are drafting, which accept a hash of attribute names to attribute values (eg. person.update!, person.update_attributes!, etc)
  • Methods used as the delete_method must be instance methods for the model you are drafting, which requires no arguments (eg. person.destroy!, person.delete, etc)

More examples

Further examples can be seen in the integration tests.

Compatibility

Ruby & Active Record versions

DraftApprove has no runtime dependencies aside from Ruby and ActiveRecord. The test suite for DraftApprove tests various combinations of Ruby and ActiveRecord. The table below shows which combinations are known to pass the test suite, and which combinations do not work. Combinations which are not listed below may or may not work - use at your own risk!

Ruby 2.6.6 Ruby 2.7.2 Ruby 3.0.0
ActiveRecord 2.4.x ✔️ ✔️ ✔️
ActiveRecord 6.0.x ✔️ ✔️ ✔️
ActiveRecord 6.1.x ❌ ¹ ✔️ ✔️

Notes

  • ¹ ActiveRecord 6.1.x is not compatible with Ruby 2.6.6

Compatible Databases

DraftApprove is currently tested against version 10.6 of the Postgres database. It is expected to work with Postgres versions 10.6 and higher.

Compatibility with other databases has not been tested, but a SQL-compliant database which supports JSON columns will likely work.

Support for database which do not support JSON columns could be added by creating a new Serialization module which can serialize, deserialize, and query drafts in another format. Take a look at the existing DraftApprove::Serialization::Json module to see what methods are required, and how you might go about this.

Frequently Asked Questions

Why am I getting ActiveRecord::RecordInvalid errors when I save a draft?

If you wish to purposefully save drafts which do not pass validations, see the Skipping validations when saving drafts section.

If you are unexpectedly getting ActiveRecord::RecordInvalid errors, a possible reason is explicit validations on foreign key columns. For example, the following would fail:

class Person < ActiveRecord::Base
  has_many :contact_addresses
  acts_as_draftable
end

class ContactAddress < ActiveRecord::Base
  belongs_to :person
  validates :person_id, presence: true  # This validation is unnecessary and can cause errors
  acts_as_draftable
end

draft_transaction = Person.draft_transaction do
  # Create a new person, and save it as a draft (note, this means p.id is nil!)
  p = Person.new(name: 'person name')
  p.save_draft!

  c = ContactAddress.new(person: p)
  c.save_draft!  # raises ActiveRecord::RecordInvalid because contact_address.person_id is nil
end

This can be fixed by removing the explicit presence: true validation of foreign key columns. Such validations should not be necessary anyway, because by default belongs_to relationships validate the associated object is not nil.

Side note: the belongs_to validations do not cause such errors when saving a draft because they check the associated object (eg. person) is not nil - rather than validating that the component attributes / columns of the association (eg. person_id) are not nil. In the example above, the belongs_to validation would check contact_address.person is not nil, which it is not - the Person object referred to has not been persisted, but it is not nil, so the validation passes.

Alternative Drafting Gems

DraftPunk and Draftsman both require changes to your existing database tables. In itself, this is not a problem, however this also potentially requires changes to your ActiveRecord Queries and any raw SQL you may be executing in order to ensure draft models or draft changes are not accidentally returned by queries or shown to end users.

This problem can be avoided using default scopes on your models. This may be a suitable solution for new projects, or projects which don't utilise much or any raw SQL queries.

See the DraftPunk documentation and Draftsman documentation on using scopes.

Drafting does not require any modifications to existing tables, and therefore has no risk of existing queries accidentally returning draft data. However, it only allows saving drafts on records which are not persisted yet. This may be suitable for projects where it is not necessary to create and approve draft updates to objects.

All the above gem also have other specific features / advantages unique to them, so before selecting the most suitable gem for your needs, it is recommended you read their documentation and trial them to find which is most suited to your project requirements.

License

MIT License

Copyright (c) 2019-2021, 38 Degrees Ltd

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

Running Tests

Pre-Requisites

DraftApprove is primarily concerned with writing data to a database, so you need a local database running to run the tests. Currently the only supported database is Postgres.

You need a postgres installation which the test can connect to using the details in spec/database.yml. ie:

  • host: localhost
  • port: 5432
  • database: draft_approve_test
  • username: draft_approve_test
  • password: draft_approve_test

Simple Testing

Run rake spec or bundle exec rspec to run tests with the current ruby installation, and the currently installed gems.

Testing with different versions of ActiveRecord

The CI config for DraftApprove tests the gem against multiple versions of ActiveRecord (ActiveRecord is the only gem which DraftApprove has a runtime dependency on).

You may wish to test with different versions of ActiveRecord locally. Testing different versions of dependencies is made easy by using the Appraisal gem, which is included as a test dependency. The Appraisal gem runs tests against multiple appraisal definitions, which is a list of dependencies - in our case, just a more specific version of ActiveRecord. You can see the appraisal definitions, and add more, in the Appraisal file in the root directory of the project.

Run bundle exec appraisal install to install the necessary gems for each appraisal definition.

Run bundle exec appraisal rspec to run the tests for every appraisal definition.

Run bundle exec appraisal <appraisal_definition_name> rspec to run the tests for a specific appraisal definition - eg. bundle exec appraisal activerecord-5-2-x rspec

Testing with different ruby versions

The CI config for DraftApprove also tests the gem against multiple versions of Ruby.

If you wish to do this locally, you may simply install another version of Ruby, install gems, and run tests. However, the recommended way to manage this is via RVM.

With rvm installed, you can install new ruby versions with rvm install <ruby-version> - eg. rvm install 3.0.0

You can then use rvm-exec <ruby-version> to run commands using a specific version of ruby. For example, rvm-exec 3.0.0 bash -c "bundle exec appraisal install && bundle exec appraisal rspec" would install the necessary gems for all appraisal definitions, and then run the tests against each appraisal definition, all using version 3.0.0 of ruby.

Installing the gem locally

To install this gem onto your local machine, run bundle exec rake install. Alternatively you may run gem build draft_approve.gemspec to generate the .gem file, then run gem install ./draft_approve-0.1.0.gem (replace 0.1.0 with the correct gem version).

Releasing a new version of the gem

To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Generating documentation

To generate the YARD documentation locally, run yard doc, which will install the documentation into the doc/ folder.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/38dgs/draft_approve. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

Code of Conduct

Everyone interacting in the DraftApprove project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

About

Gem for saving drafts of ActiveRecord models, and later approving those drafts to apply the changes

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published