ActiveRecordCompose
activermodel (activerecord) form object pattern.
Installation
To install active_record_compose
, just put this line in your Gemfile:
gem 'active_record_compose'
Then bundle
$ bundle
Usage
ActiveRecordCompose::Model basic
It wraps AR objects or equivalent models to provide unified operation. For example, the following cases are supported
Context-specific callbacks.
A callback is useful to define some processing before or after a save in a particular model.
However, if a callback is written directly in the AR model, it is necessary to consider the case where the model is updated in other contexts.
In particular, if you frequently create with test data, previously unnecessary processing will be called at every point of creation.
In addition to cost, the more complicated the callbacks you write, the more difficult it will be to create even a single test data.
If the callbacks are written in a class that inherits from ActiveRecordCompose::Model
, the AR model itself will not be polluted, and the context can be limited.
class AccountRegistration < ActiveRecordCompose::Model
def initialize(account = Account.new, attributes = {})
@account = account
super(attributes)
# By including AR instance in models, AR instance itself is saved when this model is saved.
models.push(account)
end
# By delegating these to the AR instance,
# For example, this model itself can be given directly as an argument to form_with, and it will behave as if it were an instance of the model.
delegate :id, :persisted?, to: :account
# Defines an attribute of the same name that delegates to account#name and account#email
delegate_attribute :name, :email, to: :account
# You can only define post-processing if you update through this model.
# If this is written directly into the AR model, for example, it would be necessary to consider a callback control for each test data generation.
after_commit :try_send_email_message
private
attr_reader :account
def try_send_email_message
SendEmailConfirmationJob.perform_later(account)
end
end
Validation limited to a specific context.
Validates are basically fired in all cases where the model is manipulated. To avoid this, use on: :create
, etc. to make it work only in specific cases.
and so on to work only in specific cases. This allows you to create context-sensitive validations for the same model operation.
However, this is the first step in making the model more and more complex. You will have to go around with update(context: :foo)
In some cases, you may have to go around with the context option, such as update(context: :foo)
everywhere.
By writing validates in a class that extends ActiveRecordCompose::Model
, you can define context-specific validation without polluting the AR model itself.
class AccountRegistration < ActiveRecordCompose::Model
def initialize(account = Account.new, attributes = {})
@account = account
super(attributes)
models.push(account)
end
delegate :id, :persisted?, to: :account
delegate_attribute :name, :email, to: :account
# Only if this model is used, also check the validity of the domain
before_validation :require_valid_domain
private
attr_reader :account
# Validity of the domain part of the e-mail address is also checked only when registering an account.
def require_valid_domain
e = ValidEmail2::Address.new(email.to_s)
unless e.valid?
errors.add(:email, :invalid_format)
return
end
unless e.valid_mx?
errors.add(:email, :invalid_domain)
end
end
end
account = Account.new(name: 'new account', email: 'foo@example.com')
account.valid? #=> true
account_registration = AccountRegistration.new(name: 'new account', email: 'foo@example.com')
account_registration.valid? #=> false
updating multiple models at the same time.
In an AR model, you can add, for example, autosave: true
or accepts_nested_attributes_for
to an association to update the related models at the same time.
There are ways to update related models at the same time. The operation is safe because it is transactional.
ActiveRecordCompose::Model
has an internal array called models. By adding an AR object to this models array
By adding an AR object to the models, the object stored in the models provides an atomic update operation via #save.
class AccountRegistration < ActiveRecordCompose::Model
def initialize(account = Account.new, profile = account.build_profile, attributes = {})
@account = account
@profile = profile
super(attributes)
models << account << profile
end
delegate :id, :persisted?, to: :account
delegate_attribute :name, :email, to: :account
delegate_attribute :firstname, :lastname, :age, to: :profile
private
attr_reader :account, :profile
end
Account.count #=> 0
Profile.count #=> 0
account_registration =
AccountRegistration.new(
name: 'foo',
email: 'foo@example.com',
firstname: 'bar',
lastname: 'baz',
age: 36,
)
account_registration.save!
Account.count #=> 1
Profile.count #=> 1
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 # Suppose that Account has_one Profile.
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, attributes = {})
@account = account
@profile = account.profile || account.build_profile
super(attributes)
models.push(account)
models.push(profile, destroy: :all_blank?) # destroy if all blank, otherwise save.
end
delegate_attribute :name, :email, to: :account
delegate_attribute :firstname, :lastname, :age, to: :profile
private
attr_reader :account, :profile
def all_blank? = firstname.blank && lastname.blank? && age.blank?
end
delegate_attribute
It provides a macro description that expresses access to the attributes of the AR model through delegation.
class AccountRegistration < ActiveRecordCompose::Model
def initialize(account, attributes = {})
@account = account
super(attributes)
models.push(account)
end
attribute :original_attribute, :string, default: 'qux'
delegate_attribute :name, to: :account
private
attr_reader :account
end
account = Account.new
account.name = 'foo'
registration = AccountRegistration.new(account)
registration.name #=> 'foo'
registration.name = 'bar'
account.name #=> 'bar'
Overrides #attributes
, merging attributes defined with delegate_attribute
in addition to the original attributes.
account.attributes #=> {'original_attribute' => 'qux', 'name' => 'bar'}
Callback ordering by #save
, #create
and #update
.
Sometimes, multiple AR objects are passed to the models in the arguments.
It is not strictly possible to distinguish between create and update operations, regardless of the state of #persisted?
.
Therefore, control measures such as separating callbacks with after_create
and after_update
based on the #persisted?
of AR objects are left to the discretion of the user,
rather than being determined by the state of the AR objects themselves.
class ComposedModel < ActiveRecordCompose::Model
# ...
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!
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}'
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.