Skip to content

activemodel form object pattern. it embraces multiple AR models and provides a transparent interface as if they were a single model.

License

Notifications You must be signed in to change notification settings

hamajyotan/active_record_compose

Repository files navigation

ActiveRecordCompose

activemodel (activerecord) form object pattern. it embraces multiple AR models and provides a transparent interface as if they were a single model.

CI

Table of Contents

Motivation

ActiveRecord::Base is responsible for persisting data to the database, and by defining validations and callbacks, it allows you to structure your use cases effectively. This is a crucial component of Rails. However, when a specific model starts being updated by multiple different use cases, validations and callbacks may require conditions such as on: :context or save(validate: false). As a result, the model needs to account for multiple dependent use cases, leading to increased complexity.

In such cases, ActiveModel::Model becomes useful. It provides the same interfaces as ActiveRecord::Base, such as attribute and errors, allowing it to be used similarly to an ActiveRecord model. Additionally, it enables you to define validations and callbacks within a limited context, preventing conditions related to multiple contexts from being embedded in ActiveRecord::Base validations and callbacks. This results in simpler, more maintainable code.

This gem is built on ActiveModel::Model and acts as a first-class model within the Rails context. It provides methods for performing batch and safe updates on 0..N encapsulated models, enables transparent attribute access, and facilitates access to error information.

Installation

To install active_record_compose, just put this line in your Gemfile:

gem 'active_record_compose'

Then bundle

$ bundle

Usage

Basic usage

(Below, it is assumed that there are two AR model definitions, Account and Profile, for the sake of explanation.)

class Account < ApplicationRecord
  has_one :profile  # can work without `autosave:true`
  validates :name, :email, presence: true
end

class Profile < ApplicationRecord
  belongs_to :account
  validates :firstname, :lastname, :age, presence: true
end

Here is an example of designing a model that updates both Account and Profile at the same time, using ActiveRecordCompose::Model.

class UserRegistration < ActiveRecordCompose::Model
  def initialize
    @account = Account.new
    @profile = @account.build_profile

    super()  # Don't forget to call `super()`
             # RuboCop's Lint/MissingSuper cop assists in addressing this.

    models << account << profile
    # Alternatively, it can also be written as follows:
    #     models.push(account)
    #     models.push(profile)
  end

  # Attribute declarations using ActiveModel::Attributes are supported.
  attribute :terms_of_service, :boolean

  # You can provide validation definitions limited to UserRegistration.
  # Instead of directly defining validations for Account or Profile, such
  # as `on: :create` in the context, the model itself explains the context.
  validates :terms_of_service, presence: true
  validates :email, confirmation: true

  # You can provide callback definitions limited to UserRegistration.
  # For example, if this is written directly in the AR model, you need to consider
  # callback control for data generation during tests and other situations.
  after_commit :send_email_message

  # UserRegistration behaves as if it has attributes like email, name, and age
  # For example, `email` is delegated to `account.email`,
  # and `email=` is delegated to `account.email=`.
  delegate_attribute :name, :email, to: :account
  delegate_attribute :firstname, :lastname, :age, to: :profile

  private

  attr_reader :account, :profile

  def send_email_message
    SendEmailConfirmationJob.perform_later(account)
  end
end

The above model is used as follows.

registration = UserRegistration.new

# Atomically update Account and Profile.
registration.update!(
  name: "foo",
  email: "bar@example.com",
  firstname: "taro",
  lastname: "yamada",
  age: 18,
  email_confirmation: "bar@example.com",
  terms_of_service: true,
)

By executing save, you can simultaneously update multiple models added to models. Furthermore, the save operation is performed within a database transaction, ensuring atomic processing.

user_registration.save  # Atomically update Account and Profile.
                        # In case of failure, a false value is returned.
user_registration.save! # With the bang method,
                        # an exception is raised in case of failure.

delegate_attribute

In many cases, the composed models have attributes that need to be assigned before saving. ActiveRecordCompose::Model provides delegate_attribute, allowing transparent access to those attributes."

  # UserRegistration behaves as if it has attributes like email, name, and age
  # For example, `email` is delegated to `account.email`,
  # and `email=` is delegated to `account.email=`.
  delegate_attribute :name, :email, to: :account
  delegate_attribute :firstname, :lastname, :age, to: :profile

Attributes defined with .delegate_attribute can be accessed through #attributes in the same way as the original attributes defined with .attribute.

registration = UserRegistration.new
registration.name = "foo"
registration.terms_of_service = true

# Not only the email_confirmation defined with attribute,
# but also the attributes defined with delegate_attribute are included.
registration.attributes
# => {
#   "terms_of_service" => true,
#   "email" => nil,
#   "name" => "foo",
#   "age" => nil,
#   "firstname" => nil,
#   "lastname" => nil
# }

Promotion to model from AR-model errors

When saving a composed model with #save, models that are not valid with #valid? will obviously not be saved. As a result, the #errors information can be accessed from ActiveRecordCompose::Model.

user_registration = UserRegistration.new
user_registration.email = "foo@example.com"
user_registration.email_confirmation = "BAZ@example.com"
user_registration.age = 18
user_registration.terms_of_service = true

user_registration.save
#=> false

user_registration.errors.to_a
# => [
#   "Name can't be blank",
#   "Firstname can't be blank",
#   "Lastname can't be blank",
#   "Email confirmation doesn't match Email"
# ]

I18n

When the #save! operation raises an ActiveRecord::RecordInvalid exception, it is necessary to have pre-existing locale definitions in order to construct i18n information correctly. The specific keys required are activemodel.errors.messages.record_invalid or errors.messages.record_invalid.

(Replace en as appropriate in the context.)

en:
  activemodel:
    errors:
      messages:
        record_invalid: 'Validation failed: %{errors}'

Alternatively, the following definition is also acceptable:

en:
  errors:
    messages:
      record_invalid: 'Validation failed: %{errors}'

Advanced Usage

destroy option

By adding to the models array while specifying destroy: true, you can perform a delete instead of a save on the model at #save time.

class AccountResignation < ActiveRecordCompose::Model
  def initialize(account)
    @account = account
    @profile = account.profile || account.build_profile
    super()
    models.push(account)
    models.push(profile, destroy: true)
  end

  before_save :set_resigned_at

  private

  attr_reader :account, :profile

  def set_resigned_at
    account.resigned_at = Time.zone.now
  end
end
account = Account.last

account.resigned_at.present?  #=> nil
account.profile.blank?        #=> false

account_resignation = AccountResignation.new(account)
account_resignation.save!

account.reload
account.resigned_at.present?  #=> Tue, 02 Jan 2024 22:58:01.991008870 JST +09:00
account.profile.blank?        #=> true

Conditional destroy (or save) can be written like this.

class AccountRegistration < ActiveRecordCompose::Model
  def initialize(account)
    @account = account
    @profile = account.profile || account.build_profile
    super()
    models.push(account)

    # destroy if all blank, otherwise save.
    models.push(profile, destroy: :profile_field_is_blank?)
    # Alternatively, it can also be written as follows:
    #     models.push(profile, destroy: -> { profile_field_is_blank? })
  end

  delegate_attribute :email, to: :account
  delegate_attribute :name, :age, to: :profile

  private

  attr_reader :account, :profile

  def profile_field_is_blank?
    firstname.blank? && lastname.blank? && age.blank?
  end
end

Callback ordering by #save, #create and #update.

The behavior of (before|after|around)_create and (before|after|around)_update hooks depends on the state of the persisted_flag_callback_control setting.

When persisted_flag_callback_control is set to false, the execution of #create, #update, or #save determines which callbacks will be triggered. Currently, the default value is false, but it will no longer be supported in the future.

class ComposedModel < ActiveRecordCompose::Model
  self.persisted_flag_callback_control = false # Currently defaults to false, but will no longer be supported in the future.

  # ...

  before_save { puts 'before_save called!' }
  before_create { puts 'before_create called!' }
  before_update { puts 'before_update called!' }
  after_save { puts 'after_save called!' }
  after_create { puts 'after_create called!' }
  after_update { puts 'after_update called!' }
end
model = ComposedModel.new

model.save
# before_save called!
# after_save called!

model.create
# before_save called!
# before_create called!
# after_create called!
# after_save called!

model.update
# before_save called!
# before_update called!
# after_update called!
# after_save called!

When persisted_flag_callback_control is set to true, it behaves almost like callback control in ActiveRecord. This behavior will be the default in the future.

class ComposedModel < ActiveRecordCompose::Model
  self.persisted_flag_callback_control = true # In the future, true will be the default and false will no longer be supported.

  # ...

  before_save { puts 'before_save called!' }
  before_create { puts 'before_create called!' }
  before_update { puts 'before_update called!' }
  after_save { puts 'after_save called!' }
  after_create { puts 'after_create called!' }
  after_update { puts 'after_update called!' }

  def persisted?
    # Override and return a boolish value depending on the state of the inner model.
    # For example, it could be transferred to the primary model to be manipulated.
    #
    #       # ex.)
    #       def persisted? = the_model.persisted?
    #
    true
  end
end
# when `model.persisted?` returns `true`

model = ComposedModel.new

model.save # or `model.update` (the same callbacks will be triggered in all cases).

# before_save called!
# before_update called! # when persisted? is false, before_create hook is fired here instead.
# after_update called! # when persisted? is false, after_create hook is fired here instead.
# after_save called!
# when `model.persisted?` returns `false`

model = ComposedModel.new

model.save # or `model.update` (the same callbacks will be triggered in all cases).

# before_save called!
# before_create called!
# after_create called!
# after_save called!

When persisted_flag_callback_control is true, #create is not supported.

model.create  # => raises RuntimeError

Links

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.

To install this gem onto your local machine, run bundle exec rake install. 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 the created tag, and push the .gem file to rubygems.org.

Contributing

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

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the ActiveRecord::Compose project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

About

activemodel form object pattern. it embraces multiple AR models and provides a transparent interface as if they were a single model.

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published